Normal view

Received — 11 May 2026 The Trail of Bits Blog

Escalating a Windows driver registry bug to a kernel write primitive

5 May 2026 at 13:00

We recently added a C/C++ security checklist to the Testing Handbook and challenged readers to spot the bugs in two code samples: a deceptively simple Linux ping program and a Windows driver registry handler. If you found the inet_ntoa global buffer gotcha or the missing RTL_QUERY_REGISTRY_TYPECHECK flag, nice work. If not, here’s a full walkthrough of both challenges, plus a deep dive into how the Windows registry type confusion escalates from a local denial of service to a kernel write primitive.

Since we first released the new C/C++ security checklist, we also developed a new Claude skill, c-review. It turns the checklist into bug-finding prompts that an LLM can run against a codebase. It’s also platform and threat-model aware. Run these commands to install the skill:

claude skills add-marketplace https://github.com/trailofbits/skills
claude skills enable c-review --marketplace trailofbits/skills

The Linux ping program challenge

The Linux warmup challenge we showed you in the last blog post has an obvious command injection issue.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>

#define ALLOWED_IP "127.3.3.1"

int main() {
 char ip_addr[128];
 struct in_addr to_ping_host, trusted_host;

 // get address
 if (!fgets(ip_addr, sizeof(ip_addr), stdin))
 return 1;
 ip_addr[strcspn(ip_addr, "\n")] = 0;

 // verify address
 if (!inet_aton(ip_addr, &to_ping_host))
 return 1;
 char *ip_addr_resolved = inet_ntoa(to_ping_host);

 // prevent SSRF
 if ((ntohl(to_ping_host.s_addr) >> 24) == 127)
 return 1;

 // only allowed
 if (!inet_aton(ALLOWED_IP, &trusted_host))
 return 1;
 char *trusted_resolved = inet_ntoa(trusted_host);

 if (strcmp(ip_addr_resolved, trusted_resolved) != 0)
 return 1;

 // ping
 char cmd[256];
 snprintf(cmd, sizeof(cmd), "ping '%s'", ip_addr);
 system(cmd);
 return 0;
}

There are three validations that have to be bypassed before the system call can be reached with malicious inputs:

  1. The inet_aton function “converts the Internet host address from the IPv4 numbers-and-dots notation into binary form” and “returns nonzero if the address is valid, zero if not.” Theoretically, if we provide an invalid IPv4 string as input, then the program should return early.
  2. The ntohl call aims to prevent server-side request forgery (SSRF) attacks by disallowing addresses in 127.0.0.0/8 range.
  3. The parsed IP address is normalized with an inet_ntoa call and compared against the ALLOWED_IP. We are only allowed to ping localhost, which should not be possible given the SSRF check (making the code effectively broken with this configuration).

The issue with the inet_aton function is that it accepts trailing garbage. This behavior is not documented on its man page, making it a likely source of vulnerabilities. In our challenge, one can simply send “127.0.0.1 ‘; anything #” as valid input.

The gotcha with inet_ntoa is that it returns a pointer to a global buffer. Therefore, subsequent calls to the function overwrite previous outputs. In the challenge, ip_addr_resolved and trusted_resolved are the same pointer. When we provide “1.2.3.4” as input, ip_addr_resolved points to the string “1.2.3.4”, the SSRF check passes, the second call to inet_ntoa makes the ip_addr_resolved pointer point to “127.3.3.1”, and so the strcmp check passes too.

There are a few more functions that return pointers to static buffers; these are documented in the new C/C++ Testing Handbook chapter.

The Windows driver registry challenge

We showed you this Windows Driver Framework (WDF) request handler from a Windows driver and asked you to spot the bugs.

NTSTATUS
InitServiceCallback(
 _In_ WDFREQUEST Request
)
{
 NTSTATUS status;
 PWCHAR regPath = NULL;
 size_t bufferLength = 0;


 // fetch the product registry path from the request
 status = WdfRequestRetrieveInputBuffer(Request, 4, &regPath, &bufferLength);
 if (!NT_SUCCESS(status))
 {
 TraceEvents(
 TRACE_LEVEL_ERROR,
 TRACE_QUEUE,
 "%!FUNC! Failed to retrieve input buffer. Status: %d", (int)status
 );
 return status;
 }
 /* check that the buffer size is a null-terminated
 Unicode (UTF-16) string of a sensible size */
 if (bufferLength < 4 ||
 bufferLength > 512 ||
 (bufferLength % 2) != 0 ||
 regPath[(bufferLength / 2) - 1] != L'\0')
 {
 TraceEvents(
 TRACE_LEVEL_ERROR,
 TRACE_QUEUE,
 "%!FUNC! Buffer length %d was incorrect.", (int)bufferLength
 );
 return STATUS_INVALID_PARAMETER;
 }


 ProductVersionInfo version = { 0 };
 HandlerCallback handlerCallback = NewCallback;
 int readValue = 0;
 // read the major version from the registry
 RTL_QUERY_REGISTRY_TABLE regQueryTable[2];
 RtlZeroMemory(regQueryTable, sizeof(RTL_QUERY_REGISTRY_TABLE) * 2);
 regQueryTable[0].Name = L"MajorVersion";
 regQueryTable[0].EntryContext = &readValue;
 regQueryTable[0].Flags = RTL_QUERY_REGISTRY_DIRECT;
 regQueryTable[0].QueryRoutine = NULL;
 status = RtlQueryRegistryValues(
 RTL_REGISTRY_ABSOLUTE,
 regPath,
 regQueryTable,
 NULL,
 NULL
 );
 if (!NT_SUCCESS(status))
 {
 TraceEvents(
 TRACE_LEVEL_ERROR,
 TRACE_QUEUE,
 "%!FUNC! Failed to query registry. Status: %d", (int)status
 );
 return status;
 }
 TraceEvents(
 TRACE_LEVEL_INFORMATION,
 TRACE_QUEUE,
 "%!FUNC! Major version is %d",
 (int)readValue
 );
 version.Major = readValue;
 if (version.Major < 3)
 {
 // versions prior to 3.0 need an additional check
 RtlZeroMemory(regQueryTable, sizeof(RTL_QUERY_REGISTRY_TABLE) * 2);
 regQueryTable[0].Name = L"MinorVersion";
 regQueryTable[0].EntryContext = &readValue;
 regQueryTable[0].Flags = RTL_QUERY_REGISTRY_DIRECT;
 regQueryTable[0].QueryRoutine = NULL;
 status = RtlQueryRegistryValues(
 RTL_REGISTRY_ABSOLUTE,
 regPath,
 regQueryTable,
 NULL,
 NULL
 );
 if (!NT_SUCCESS(status))
 {
 TraceEvents(
 TRACE_LEVEL_ERROR,
 TRACE_QUEUE,
 "%!FUNC! Failed to query registry. Status: %d",
 (int)status
 );
 return status;
 }
 TraceEvents(
 TRACE_LEVEL_INFORMATION,
 TRACE_QUEUE,
 "%!FUNC! Minor version is %d", (int)readValue
 );
 version.Minor = readValue;
 if (!DoesVersionSupportNewCallback(version))
 {
 handlerCallback = OldCallback;
 }
 }
 SetGlobalHandlerCallback(handlerCallback);
}

The intended behavior of the code is to read some software version information from the registry using the RtlQueryRegistryValues API, then select one of two possible callback functions depending on that version information.

An attacker-controlled registry path

The first bug is that the path to the registry key is provided in the request, without validating the path string or checking that the caller is authorized to access the specified registry key. This means that anyone who can call into this handler can pick which registry key gets read, even if they ordinarily wouldn’t have access to that key. How this path string is interpreted depends on the RelativeTo parameter of the RtlQueryRegistryValues call. In this case, RelativeTo is set to RTL_REGISTRY_ABSOLUTE, which means that the path will be treated as an absolute path to a registry key object (e.g., \Registry\User\CurrentUser). There are two main reasons why this is a potential security issue.

First, if an attacker can control which registry key is being read, then they can point it at a registry key they control the contents of, allowing them to further manipulate the driver behavior. This may lead to logical inconsistencies (e.g., the wrong callback being set) or, as we will see shortly, enable exploitation of security issues elsewhere in the code.

Second, this enables a confused deputy attack that can be used to leak registry information that would normally be inaccessible to the user due to access controls. For example, a registry key might have a DACL applied that prevents normal users from enumerating its subkeys or reading any of the values inside those keys. Since the handler doesn’t check whether the call has sufficient rights to read the key, and the code emits a trace message and passes back the status code from RtlQueryRegistryValues, it can be used as an oracle to check for the existence of any registry key. It can also be used to leak any registry value named MajorVersion (and sometimes also MinorVersion) anywhere in the registry, but this is unlikely to be particularly useful in practice.

Missing type checks with RTL_QUERY_REGISTRY_DIRECT

The more serious bugs in this case arise from the flags set in the RTL_QUERY_REGISTRY_TABLE structs. The RtlQueryRegistryValues API takes in an array of these structs, terminated by an all-zero entry, to describe which registry values should be read from the specified key and how they should be processed and returned. There are two primary modes of operation here: callback or direct. In callback mode, which is the default, the QueryRoutine field of the struct points to a callback function that receives the value read from the registry. In direct mode, the QueryRoutine field is ignored and the value is instead written directly to a buffer whose location is passed in the EntryContext field. Direct mode is selected by including RTL_QUERY_REGISTRY_DIRECT in the Flags field.

In our example, the MajorVersion value is read using the following code:

HandlerCallback handlerCallback = NewCallback;
 int readValue = 0;
 // read the major version from the registry
 RTL_QUERY_REGISTRY_TABLE regQueryTable[2];
 RtlZeroMemory(regQueryTable, sizeof(RTL_QUERY_REGISTRY_TABLE) * 2);
 regQueryTable[0].Name = L"MajorVersion";
 regQueryTable[0].EntryContext = &readValue;
 regQueryTable[0].Flags = RTL_QUERY_REGISTRY_DIRECT;
 regQueryTable[0].QueryRoutine = NULL;
 status = RtlQueryRegistryValues(
 RTL_REGISTRY_ABSOLUTE,
 regPath,
 regQueryTable,
 NULL,
 NULL
 );

Here, RTL_QUERY_REGISTRY_DIRECT is used to select direct mode, and the buffer points to readValue, which is an integer variable on the stack. You might notice something important, though: at no point has the code specified what type of value is being read, nor has it specified the size of the buffer. It is clear from the context that this code is expecting to read a REG_DWORD, but what if the MajorVersion value isn’t a REG_DWORD?

A first attempt at exploitation

Let’s try to exploit this using a REG_QWORD. A REG_DWORD value is a 32-bit unsigned integer, whereas a REG_QWORD is a 64-bit unsigned integer, so if we make MajorVersion a REG_QWORD value instead, then we should be able to overwrite four bytes immediately after readValue on the stack. Since HKEY_CURRENT_USER is writable by low-privilege users, we can create a key somewhere in there, place a REG_QWORD value called MajorVersion in there, and pass the path of that key to the driver. And success, we get a BSOD!

Except… it’s not quite what we wanted. The bugcheck code is KERNEL_SECURITY_CHECK_FAILURE, which isn’t really what we would expect if we successfully overwrote some of the stack. Why is this happening? The answer is in the documentation:

Starting with Windows 8, if an RtlQueryRegistryValues call accesses an untrusted hive, and the caller sets the RTL_QUERY_REGISTRY_DIRECT flag for this call, the caller must additionally set the RTL_QUERY_REGISTRY_TYPECHECK flag. A violation of this rule by a call from user mode causes an exception. A violation of this rule by a call from kernel mode causes a 0x139 bug check (KERNEL_SECURITY_CHECK_FAILURE).

Only system hives are trusted. An RtlQueryRegistryValues call that accesses a system hive does not cause an exception or a bug check if the RTL_QUERY_REGISTRY_DIRECT flag is set and the RTL_QUERY_REGISTRY_TYPECHECK flag is not set. However, as a best practice, the RTL_QUERY_REGISTRY_TYPECHECK flag should always be set if the RTL_QUERY_REGISTRY_DIRECT flag is set.

Similarly, in versions of Windows before Windows 8, as a best practice, an RtlQueryRegistryValues call that sets the RTL_QUERY_REGISTRY_DIRECT flag should additionally set the RTL_QUERY_REGISTRY_TYPECHECK flag. However, failure to follow this recommendation does not cause an exception or a bug check. This protective behavior was introduced as a response to MS11-011, in which this registry type confusion bug was first reported.

To summarize, if you try to read from an untrusted registry hive using RtlQueryRegistryValues with RTL_QUERY_REGISTRY_DIRECT set but without also setting RTL_QUERY_REGISTRY_TYPECHECK, then Windows will automatically raise a bugcheck to crash the system and prevent the operation from succeeding.

The RTL_QUERY_REGISTRY_TYPECHECK flag allows the caller to specify an expected type as part of the query table entry, thus mitigating the type confusion bug. Since this flag is not set in our example, a bugcheck will be triggered if we attempt to read from any registry hive other than the following trusted system hives:

  • \REGISTRY\MACHINE\HARDWARE
  • \REGISTRY\MACHINE\SOFTWARE
  • \REGISTRY\MACHINE\SYSTEM
  • \REGISTRY\MACHINE\SECURITY
  • \REGISTRY\MACHINE\SAM

HKEY_CURRENT_USER is not included within this set, which explains why we saw the KERNEL_SECURITY_CHECK_FAILURE bugcheck when we tried to exploit it that way. This downgrades us from a potential kernel privilege escalation bug to a local denial of service. Still a bug, but not quite as exciting.

Finding writable keys in trusted hives

However, who says we can’t write values somewhere within these trusted hives? All it takes is a single key within one of those hives with a DACL that allows a lower-privileged user to write to it. Finding these isn’t too hard; the NtObjectManager powershell module has a command named Get-AccessibleKey that is perfect for the task:

Get-AccessibleKey \Registry\Machine -Recurse -Access SetValue

This command searches recursively within the \Registry\Machine object namespace for keys that the current process has permissions to set values within. Running it as a regular desktop user returns thousands of options that can be written without UAC elevation! Nice.

However, for style points, we can go one step further. Mandatory integrity control (MIC), one of the key access control features in Windows that underpins UAC, allows processes to run with higher or lower privileges than would normally be assigned to the user that ran them. Most desktop processes run at the medium integrity level (IL). Elevating a process via UAC (often referred to as “run as administrator”) typically increases the process’s IL to high. There is also a low IL, which is often used to sandbox certain processes for security reasons, significantly limiting which resources they can access. Any securable object on Windows can have a mandatory label applied to its system access control list (SACL), and that mandatory label specifies the ILs that are allowed to access the object. The SACL is checked before the DACL, meaning that the IL check must pass even if the DACL would normally grant the user permissions to access the object. This means that a process running with a low-integrity security token cannot access a medium-integrity object, and a process running with a medium-integrity security token cannot access a high-integrity object. So, can we find any cases where we could write to one of the trusted system hives from a low-integrity process?

To check for keys that are accessible at a low IL, the first thing we want to do is duplicate our process token and apply a low integrity label to it:

$token = Get-NtToken -Primary -Duplicate -IntegrityLevel Low

This gives us a copy of our current process’s security token that behaves as if we were running at a low IL. Using this, we then rerun the scan, passing in that modified token:

Get-AccessibleKey \Registry\Machine -Recurse -Access SetValue -Token $token

This does actually return a few results, on both Windows 10 and 11. Here are two of the most interesting:

\REGISTRY\MACHINE\SOFTWARE\Microsoft\DRM \REGISTRY\MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\PlayReady\Troubleshooter

Both of these keys allow a low-integrity token to write to them. The DRM key’s DACL has fairly complex permissions applied but grants the Set Value permission to the Everyone group. The PlayReady\Troubleshooter key’s DACL grants Full Control to Users, ALL APPLICATION PACKAGES, and ALL RESTRICTED APP PACKAGES. Either of these two keys can be abused to plant controlled registry values within a trusted system hive from a low privilege level.

(Note: Whether or not the driver’s request endpoint can be called from a low IL is a different matter, but this is just for fun and style points, so let’s ignore that for now.)

If we set a REG_QWORD value called MajorVersion in the DRM key, then pass that key’s path to the WDF handler, we can now overwrite four bytes of stack past the end of readValue with values that we control. Since handlerCallback was declared adjacent to readValue, there’s a chance that we can overwrite half of that function pointer! If that callback is called later, then we obtain partial control over the instruction pointer, which is a fairly strong primitive for local privilege escalation (LPE). This does depend on stack alignment, however, and it would not be surprising if the 32-bit readValue variable ended up 64-bit aligned, leaving a gap, so this approach may not get us far in practice.

Can we do better?

A string is a type of integer, right?

Ok, so far we’ve only explored what happens when we exploit the type confusion with REG_QWORD, but what happens if we use REG_SZ?

“Samuel L. Jackson meme”

In the case of REG_SZ (i.e., a string value), the documentation says the following about RtlQueryRegistryValues’ behavior in direct mode:

A null-terminated Unicode string (such as REG_SZ, REG_EXPAND_SZ): EntryContext must point to an initialized UNICODE_STRING structure. If the Buffer member of UNICODE_STRING is NULL, the routine allocates storage for the string data. Otherwise, it stores the string data in the buffer that Buffer points to.

Let’s try exploiting this. RtlQueryRegistryValues will interpret the EntryContext field as if it were a UNICODE_STRING struct, but it’s actually pointing at readValue, which is an int. Here’s what a UNICODE_STRING looks like:

typedef struct _UNICODE_STRING {
 USHORT Length;
 USHORT MaximumLength;
 PWSTR Buffer;
} UNICODE_STRING, *PUNICODE_STRING;

In the first call that the code makes to RtlQueryRegistryValues, when reading MajorVersion, the value of readValue has been initialized to zero. Since readValue is four bytes and a USHORT is two bytes, interpreting readValue as a UNICODE_STRING at that time will result in both Length and MaximumLength being zero and Buffer containing whatever’s immediately after readValue in the stack. Since the length of the buffer is zero, RtlQueryRegistryValues will just return STATUS_BUFFER_TOO_SMALL and not attempt to write to the Buffer field.

However, let’s take a look at the second call to RtlQueryRegistryValues:

version.Major = readValue;
 if (version.Major < 3)
 {
 // versions prior to 3.0 need an additional check
 RtlZeroMemory(regQueryTable, sizeof(RTL_QUERY_REGISTRY_TABLE) * 2);
 regQueryTable[0].Name = L"MinorVersion";
 regQueryTable[0].EntryContext = &readValue;
 regQueryTable[0].Flags = RTL_QUERY_REGISTRY_DIRECT;
 regQueryTable[0].QueryRoutine = NULL;
 status = RtlQueryRegistryValues(
 RTL_REGISTRY_ABSOLUTE,
 regPath,
 regQueryTable,
 NULL,
 NULL
 );
 // ...

This part of the code first checks if the MajorVersion value is less than three and, if so, reads the MinorVersion value using the same approach as before. A key observation here is that readValue is not reinitialized between the calls. This gives us some extra control: by leaving MajorVersion as a REG_DWORD, as originally intended by the code, we can have the first RtlQueryRegistryValues call load a value into readValue. Then, when the second call to RtlQueryRegistryValues is made, to read MinorVersion, we control the first four bytes of data pointed to by EntryContext. If MinorVersion is a REG_SZ value, a type confusion occurs where RtlQueryRegistryValues expects EntryContext to point to a UNICODE_STRING, causing the contents of the MajorVersion integer to be reinterpreted as the Length and MaximumLength fields. The only restriction is that we need the major version check to pass (i.e., version.Major must be less than 3) in order for the second registry query to take place. However, this turns out to be easy: if we set the MajorVersion value to 0xF000F002, the code will interpret this as -268374014 because readValue is a signed 32-bit integer. The Length and MaximumLength fields, however, are unsigned 16-bit integers, causing the 0xF000F002 value to get interpreted as the following when type confused as a UNICODE_STRING:

USHORT Length = F000;
 USHORT MaximumLength = F002;
 PWSTR Buffer = ????????`????????;

The Buffer field ends up pointing at whatever’s next in the stack. If we combine this current approach with the REG_QWORD trick from before, we can also overwrite four bytes of the Buffer pointer during the MajorVersion read. This means we partially control the address being written to, we fully control the length of what is written, and we can write any UTF-16 string there. This gets us a semi-controlled write-what-where primitive in the kernel. Nice!

But can we do even better?

A fully controlled stack overwrite with REG_BINARY

Let’s take a look at what happens if we try a REG_BINARY value instead. Here’s what the documentation has to say about such values in direct mode:

Nonstring data with size, in bytes, greater than sizeof(ULONG): The buffer pointed to by EntryContext must begin with a signed LONG value. The magnitude of the value must specify the size, in bytes, of the buffer. If the sign of the value is negative, RtlQueryRegistryValues will only store the data of the key value. Otherwise, it will use the first ULONG in the buffer to record the value length, in bytes, the second ULONG to record the value type, and the rest of the buffer to store the value data.

This one is a bit more complicated, with two possible cases for the format of the buffer. In both cases, the buffer pointed to by EntryContext is expected to be prefilled with a signed LONG value that tells RtlQueryRegistryValues how large the buffer is. A LONG is just a 32-bit integer, so a signed LONG is functionally equivalent to int for this case. The interesting part is that this length value can either be positive or negative. If the value is negative, the API will copy the REG_BINARY data directly into the buffer pointed to by EntryContext. If the value is positive, it will first write the length of the REG_BINARY data into the first ULONG of the buffer, then it will write the REG_BINARY type value into the second ULONG of the buffer, and finally it will copy the REG_BINARY data into the remainder of the buffer.

You may have figured out the exploit already here. The MinorVersion registry value is only read when the MajorVersion is less than 3. If we set MajorVersion to some negative number, this check will pass. This negative number ends up left in readValue for the second RtlQueryRegistryValues call. If the MinorVersion value is a REG_BINARY, RtlQueryRegistryValues treats the first ULONG in the “buffer” as being the signed length field. Since our “buffer” is just whatever was in readValue from the previous call, this causes RtlQueryRegistryValues to copy the contents of the registry value into the “buffer,” which is really just stack memory starting at readBytes. Since we control the magnitude of the negative number, we therefore control the purported length of the buffer, allowing us to control the length of the overwrite. And, since the contents of the REG_BINARY value can be anything we like, it means we control what is overwritten.

For example, if we create a REG_DWORD value called MajorVersion with a value of 0xFFFFFFF4, then create a REG_BINARY value called MinorVersion with a value of 00 00 00 00 DE AD BE EF DE AD BE EF, this causes the first RtlQueryRegistryValues call to fill readValue with -12, which the second RtlQueryRegistryValues call interprets as a 12-byte buffer where only the binary should be copied. This results in RtlQueryRegistryValues copying 00 00 00 00 into readValue, then writing DE AD BE EF DE AD BE EF onto the stack afterwards. Assuming that the handlerCallback function pointer is stored after the readValue variable on the stack, we can now overwrite it with whatever we like. If this callback is invoked anywhere in the future, we gain control over the instruction pointer, leading to a kernel LPE.

But can we do even better still? If you think you can, get in touch! We’d love to hear your tips and tricks.

Your turn

These challenges only scratch the surface of what the C/C++ Testing Handbook chapter covers—from seccomp sandbox escapes to Windows path traversal via WorstFit Unicode bugs. Read the chapter and follow the checklist against a codebase you know well. Pair it with a run of the c-review skill, if you’re inclined. If you find a pattern we haven’t documented yet, open a PR. We’d especially love to hear from anyone who found a cleaner exploitation path for the driver challenge than the ones we showed here. And, as always, if you need help securing your C/C++ systems, contact us.

Extending Ruzzy with LibAFL

29 April 2026 at 13:00

LibAFL is all the rage in the fuzzing community these days, especially with LLVM’s libFuzzer being placed in maintenance mode. Written in Rust, LibAFL claims improved performance, modularity, state-of-the-art fuzzing techniques, and libFuzzer compatibility. For these reasons, I set out to add LibAFL support to Ruzzy, our coverage-guided fuzzer for pure Ruby code and Ruby C extensions. This gives Ruby developers and security researchers access to a more advanced and actively maintained fuzzing engine without changing how they write their fuzzing harnesses.

Ruzzy was originally built on top of LLVM’s libFuzzer, so using LibAFL’s compatibility layer should be easy enough. However, digging around in the internals of complex systems is never quite as simple as it seems. In this post, I will investigate some of the deep plumbing inside these fuzzing engines, take a detour into executable and linkable format (ELF) files, and ultimately add LibAFL support to Ruzzy.

Building with libafl_libfuzzer

Ruzzy currently supports Linux, so I use a Dockerfile for development and for production fuzzing campaigns. To that end, using a similar Dockerfile for LibAFL support is the simplest integration point. LibAFL provides excellent documentation and build scripts to use it as a standalone library. We need to build LibAFL as a standalone library because Ruzzy uses libFuzzer as a library.

Following along with the standalone libafl_libfuzzer documentation, and with the build.sh script in hand, we can build libFuzzer.a. This is the archive that will ultimately be linked into Ruzzy’s C extension and used to fuzz our target. Here are the relevant lines from our new Dockerfile:

# Install Rust nightly via rustup
RUN wget -qO- https://sh.rustup.rs | sh -s -- \
 -y \
 --default-toolchain nightly \
 --component llvm-tools

ENV PATH="/root/.cargo/bin:${PATH}"

# Clone LibAFL
RUN git clone --depth 1 https://github.com/AFLplusplus/LibAFL /libafl

# Build libFuzzer.a from LibAFL's libfuzzer runtime
WORKDIR /libafl/crates/libafl_libfuzzer_runtime

RUN bash build.sh
Figure 1: Building LibAFL’s libFuzzer.a (Dockerfile.LibAFL)

This all goes smoothly and gives us our desired output: libFuzzer.a. Next, we need to make a slight tweak to Ruzzy’s mechanism for determining a fuzzer_no_main library. Using fuzzer_no_main and -fsanitize=fuzzer-no-link is libFuzzer’s standard mechanism for fuzzing code that provides its own main function. This makes sense for interpreted languages because the interpreter, well, brings its own main.

To accomplish the desired flexibility in Ruzzy, we simply need to prioritize an ENV variable, if present, that specifies the fuzzer_no_main library path, then fall back to Clang’s defaults if not:

FUZZER_NO_MAIN_LIB_ENV = 'FUZZER_NO_MAIN_LIB'
...
fuzzer_no_main_lib = ENV.fetch(FUZZER_NO_MAIN_LIB_ENV, nil)

if fuzzer_no_main_lib
 LOGGER.info("Using #{FUZZER_NO_MAIN_LIB_ENV}=#{fuzzer_no_main_lib}")
 unless File.exist?(fuzzer_no_main_lib)
 LOGGER.error("#{FUZZER_NO_MAIN_LIB_ENV} file does not exist: #{fuzzer_no_main_lib}")
 exit(1)
 end
else
 fuzzer_no_main_libs = [
 'libclang_rt.fuzzer_no_main.a',
 'libclang_rt.fuzzer_no_main-aarch64.a',
 'libclang_rt.fuzzer_no_main-x86_64.a'
 ]
 fuzzer_no_main_lib = fuzzer_no_main_libs.map { |lib| get_clang_file_name(lib) }.find(&:itself)

 unless fuzzer_no_main_lib
 LOGGER.error("Could not find fuzzer_no_main using #{CC}.")
 LOGGER.error("Please include #{CC} in your path or specify #{FUZZER_NO_MAIN_LIB_ENV} ENV variable.")
 exit(1)
 end
end
Figure 2: Allowing an ENV override for the fuzzing library (ext/cruzzy/extconf.rb)

Now, let’s build Ruzzy with LibAFL’s libFuzzer.a:

# Copy LibAFL's libFuzzer.a from builder stage
COPY --from=libafl-builder /libafl/crates/libafl_libfuzzer_runtime/ libFuzzer.a /usr/lib/libFuzzer.a

# Point Ruzzy at LibAFL's libFuzzer instead of clang's built-in
ENV FUZZER_NO_MAIN_LIB="/usr/lib/libFuzzer.a"

WORKDIR ruzzy/
COPY . .
RUN gem build
RUN RUZZY_DEBUG=1 gem install --development --verbose ruzzy-*.gem
Figure 3: Building Ruzzy with LibAFL using a custom FUZZER_NO_MAIN_LIB (Dockerfile.LibAFL)

However, this produces the following error:

INFO -- : Using FUZZER_NO_MAIN_LIB=/usr/lib/libFuzzer.a
DEBUG -- : Search for libclang_rt.asan.a using clang-21: success=true exists=false
DEBUG -- : Search for libclang_rt.asan-aarch64.a using clang-21: success=true exists=true
DEBUG -- : Search for libclang_rt.asan-x86_64.a using clang-21: success=true exists=false
DEBUG -- : Creating /usr/lib/llvm-21/lib/clang/21/lib/linux/libclang_rt.asan-aarch64.a sanitizer archive at /tmp/20260320-20-683d0b
DEBUG -- : Merging sanitizer at /tmp/20260320-20-683d0b with libFuzzer at /usr/lib/libFuzzer.a to asan_with_fuzzer.so
/usr/bin/ld: /usr/lib/libFuzzer.a(libFuzzer.o): .preinit_array section is not allowed in DSO
/usr/bin/ld: failed to set dynamic section sizes: nonrepresentable section on output
clang++-21: error: linker command failed with exit code 1 (use -v to see invocation)
ERROR -- : The clang++-21 shared object merging command failed.
*** extconf.rb failed ***
Figure 4: Failure linking libFuzzer.a

The key error here is “.preinit_array section is not allowed in DSO.” This was a new one for me. What is a .preinit_array section, and what is this error trying to tell me? The relevant ELF documentation states the following:

Finally, an executable file may have pre-initialization functions. These functions are executed after the dynamic linker has built the process image and performed relocations but before any shared object initialization functions. Pre-initialization functions are not permitted in shared objects.
...
The DT_PREINIT_ARRAY table is processed only in an executable file; it is ignored if contained in a shared object.

So dynamic shared objects (DSOs) cannot contain a .preinit_array section. This is exactly what the error told us. .init, .ctors, .init_array, and .preinit_array are all mechanisms for running code before main starts in an ELF binary. Exploring each of these and the order in which they’re run is beyond the scope of this post (see this explanation), but suffice it to say we need to sidestep this libafl_libfuzzer implementation detail. Here’s how LibAFL and libFuzzer differ in this regard:

$ objdump -h /usr/lib/libFuzzer.a | grep 'init_array'
3100 .init_array 00000228 ...
5047 .preinit_array 00000008 ...
32136 .init_array.00099 00000008 ...
37083 .init_array.90 00000010 ...

$ objdump -h libclang_rt.fuzzer-aarch64.a | grep 'init_array'
 40 .init_array 00000008 ...
 57 .init_array 00000008 ...

$ objdump -h libclang_rt.fuzzer_no_main-aarch64.a | grep 'init_array'
 40 .init_array 00000008 ...
 57 .init_array 00000008 ...

$ objdump -h libclang_rt.fuzzer_interceptors-aarch64.a | grep 'init_array'
 21 .preinit_array 00000008 ...
Figure 5: .init_array vs. .preinit_array in LibAFL vs. libFuzzer

The figure above shows that LibAFL’s archive contains both .init_array and .preinit_array sections whereas Clang’s libFuzzer splits them across different files. Since LibAFL uses the same interceptor code as Clang, it also defines the same .preinit_array. The problem is that LibAFL provides libfuzzer_no_link_main and libfuzzer_interceptors features, but we cannot easily toggle them at build time.

This leaves us with two options: the proper solution, which is to propose a change upstream that allows these features to be toggled at build time, and the hacky, make-it-work solution. I wanted to keep moving forward and see this work end-to-end, so I started with the hacky solution. This required having a trick up our sleeve: GNU ld enforces the .preinit_array-in-a-DSO constraint, but LLVM ld does not. So we can modify Ruzzy’s build procedure to allow passing a user defined ld path at build time:

diff --git a/Dockerfile.LibAFL b/Dockerfile.LibAFL
index 5d0f9516..df6be2e2 100644
--- a/Dockerfile.LibAFL
+++ b/Dockerfile.LibAFL
@@ -54,9 +54,12 @@ RUN echo "deb http://apt.llvm.org/bookworm/ llvm-toolchain-bookworm-$LLVM_VERSION
 && echo "deb-src http://apt.llvm.org/bookworm/ llvm-toolchain-bookworm-$LLVM_VERSION main" >> /etc/apt/sources.list.d/ llvm.list \
 && wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key > /etc/apt/trusted.gpg.d/apt.llvm.org.asc

+# Install lld alongside clang. LibAFL's libFuzzer.a contains a .preinit_array
+# .preinit_array section that the GNU linker rejects in shared objects.
+# lld handles this correctly.
 RUN apt update && apt install -y \
 build-essential \
 clang-$LLVM_VERSION \
+ lld-$LLVM_VERSION \
 && rm -rf /var/lib/apt/lists/*

 ENV APP_DIR="/app"
@@ -69,6 +72,10 @@ ENV LDSHARED="clang-$LLVM_VERSION -shared"
 ENV LDSHAREDXX="clang++-$LLVM_VERSION -shared"
 ENV ASAN_SYMBOLIZER_PATH="/usr/bin/llvm-symbolizer-$LLVM_VERSION"

+# Use lld for linking. LibAFL's libFuzzer.a contains a .preinit_array section
+# that the GNU linker rejects in shared objects. lld handles this correctly.
+ENV LD="lld-$LLVM_VERSION"
+
 ENV MAKE="make --environment-overrides V=1"

 ENV ASAN_OPTIONS="symbolize=1:allocator_may_return_null=1:
detect_leaks=0:use_sigaltstack=0"
diff --git a/ext/cruzzy/extconf.rb b/ext/cruzzy/extconf.rb
index 6f474e62..260fcae6 100644
--- a/ext/cruzzy/extconf.rb
+++ b/ext/cruzzy/extconf.rb
@@ -19,6 +19,7 @@ LOGGER.level = ENV.key?('RUZZY_DEBUG') ?
Logger::DEBUG : Logger::INFO
 CC = ENV.fetch('CC', 'clang')
 CXX = ENV.fetch('CXX', 'clang++')
 AR = ENV.fetch('AR', 'ar')
+LD = ENV.fetch('LD', 'ld')
 FUZZER_NO_MAIN_LIB_ENV = 'FUZZER_NO_MAIN_LIB'

 LOGGER.debug("Ruby CC: #{RbConfig::CONFIG['CC']}")
@@ -66,6 +67,7 @@ def merge_sanitizer_libfuzzer_lib(sanitizer_lib,
fuzzer_no_main_lib, merged_outp
 '-ldl',
 '-lstdc++',
 '-shared',
+ "-fuse-ld=#{LD}",
 '-o',
 merged_output
 )
@@ -145,5 +147,6 @@ merge_sanitizer_libfuzzer_lib(
 $LOCAL_LIBS = fuzzer_no_main_lib

 $LIBS << ' -lstdc++'
+$DLDFLAGS << " -fuse-ld=#{LD}"

 create_makefile('cruzzy/cruzzy')
Figure 6: Allow a user-specified ld binary

And now the Docker build works! But building the fuzzing libraries, Ruby C extension, and Docker image is only the first step. We still have to run the fuzzer, which comes with its own set of challenges.

As for the proper fix I mentioned earlier, we did propose it upstream in this pull request. Once that’s merged, we can run the build script with --cargo-args "--no-default-features --features no_link_main" and avoid the ld hack. Now, on to running the fuzzer.

Fuzzing with LibAFL

Ruzzy includes its own “dummy” C extension for testing the fuzzer and making sure everything is working as expected. We can use this to test out our LibAFL changes and make sure they’re working properly. After building the fuzzer and finally being able to start it, I got the following error:

$ docker run --rm ruzzy-libafl -runs=100000
thread '<unnamed>' (9) panicked at src/fuzz.rs:275:5:
No maps available; cannot fuzz!
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
fatal runtime error: failed to initiate panic, error 2786066624, aborting
/usr/local/bundle/gems/ruzzy-0.7.0/lib/ruzzy.rb:15: [BUG] Aborted at 0x0000000000000009
ruby 4.0.1 (2026-01-13 revision e04267a14b) +PRISM [aarch64-linux]

-- Control frame information -----------------------------------------------
c:0005 p:---- s:0022 e:000021 l:y b:---- CFUNC :c_fuzz
c:0004 p:0011 s:0016 e:000015 l:y b:0001 METHOD /usr/local/bundle/gems/ruzzy-0.7.0/lib/ruzzy.rb:15
c:0003 p:0008 s:0010 E:001390 l:y b:0001 METHOD /usr/local/bundle/gems/ruzzy-0.7.0/lib/ruzzy.rb:28
c:0002 p:0010 s:0006 e:000005 l:n b:---- EVAL -e:1 [FINISH]
c:0001 p:0000 s:0003 E:000940 l:y b:---- DUMMY [FINISH]

-- Ruby level backtrace information ----------------------------------------
-e:1:in '<main>'
/usr/local/bundle/gems/ruzzy-0.7.0/lib/ruzzy.rb:28:in 'dummy'
/usr/local/bundle/gems/ruzzy-0.7.0/lib/ruzzy.rb:15:in 'fuzz'
/usr/local/bundle/gems/ruzzy-0.7.0/lib/ruzzy.rb:15:in 'c_fuzz'
...
Figure 7: Runtime error when starting the fuzzer

The key error here is “No maps available; cannot fuzz!” This LibAFL error occurs when the SanitizerCoverage state is not initialized properly. To understand this discrepancy between LibAFL and libFuzzer, we must first understand what SanitizerCoverage is and how it works.

SanitizerCoverage tracks code coverage information during a fuzzing campaign to improve performance. Simple heuristics like “if we’ve discovered new code coverage, then continue to mutate relevant inputs to better explore these code paths” are powerful fuzzing primitives. The underlying theory is that higher code coverage results in more crashes and bugs (I’m oversimplifying, but you get the point). To that end, a fuzzing engine needs a mechanism for initializing and tracking coverage information.

SanitizerCoverage offers a variety of ways to track coverage information, all of which require a mechanism to initialize state at the beginning of a fuzzing campaign. For example, the documentation offers pc-guard, 8bit-counters, bool-flag, and pc-table tracing mechanisms, each with a corresponding init function. These init functions are eventually lowered and represented as .init_array entries in ELF files (.init_array strikes again). This means that, ultimately, coverage initialization functionality is called when the DSO is loaded at runtime.

Back to the error at hand: why is LibAFL saying “No maps available; cannot fuzz!” while LLVM’s libFuzzer starts up just fine? The key distinction is that libFuzzer lazily allows new coverage counter arrays to be included at runtime and does not complain if none exist at startup. LibAFL, however, requires them to be defined when the fuzzer starts. Compare the following sequence of events:

So coverage init functions are called at DSO load time, after which the fuzzing engine may or may not check for their existence depending on implementation. To fully understand the cause of this error, we have to go back and better understand how Ruzzy runs its “dummy” C extension. The Ruzzy Docker image runs the “dummy” code by default via its entrypoint:

#!/bin/bash

LD_PRELOAD=$(ruby -e 'require "ruzzy"; print Ruzzy::ASAN_PATH') \
 ruby -e 'require "ruzzy"; Ruzzy.dummy' -- "$@"
Figure 8: Docker image entrypoint (entrypoint.sh)

Ruzzy.dummy corresponds to the following code:

def fuzz(test_one_input, args = DEFAULT_ARGS)
 c_fuzz(test_one_input, args) # STEP 3: Call Ruzzy.c_fuzz (in C extension)
end

def dummy_test_one_input(data) # STEP 4: Eventually call Ruzzy.dummy_test_one_input
 # This 'require' depends on LD_PRELOAD, so it's placed inside the function
 # scope. This allows us to access EXT_PATH for LD_PRELOAD and not have a
 # circular dependency.
 require 'dummy/dummy'

 c_dummy_test_one_input(data)
end

def dummy # STEP 1: Call Ruzzy.dummy
 fuzz(->(data) { dummy_test_one_input(data) }) # STEP 2: Call Ruzzy.fuzz
end
Figure 9: Ruzzy.dummy call chain (lib/ruzzy.rb)

If you’re searching for the bug, then the body of dummy_test_one_input may provide a hint. The issue here is that require 'dummy/dummy' is called too late. This require statement is actually loading the compiled Ruby C extension shared object. Remember what we learned above about loading shared objects? This shared object contains an .init_array function that initializes the coverage counter state. libFuzzer lazily uses coverage counter state, so it is not so sensitive about the ordering of events. LibAFL, however, requires that this state already be initialized before it begins fuzzing.

Ruzzy.dummy calls fuzz with a lambda that calls dummy_test_one_input. But because dummy_test_one_input is passed in a lambda and not invoked until the fuzzer starts, LibAFL errors out in the call to c_fuzz (c_fuzz calls LLVMFuzzerRunDriver). This makes sense given that the initial Ruby error traceback pointed at c_fuzz. So we end up with a quite minimal patch:

diff --git a/lib/ruzzy.rb b/lib/ruzzy.rb
index d5e9ae61..be5f8339 100644
--- a/lib/ruzzy.rb
+++ b/lib/ruzzy.rb
@@ -25,6 +25,11 @@ module Ruzzy
 end

 def dummy
+ # Load the instrumented shared object before calling fuzz so its coverage
+ # maps are registered before LLVMFuzzerRunDriver starts. Some fuzzer
+ # runtimes (e.g. LibAFL) require coverage maps to exist upfront.
+ require 'dummy/dummy'
+
 fuzz(->(data) { dummy_test_one_input(data) })
 end
Figure 10: Ruzzy.dummy initialization patch

With the ld and initialization patches, LibAFL finally works (!):

$ docker run --rm ruzzy-libafl -runs=100000
...
 (CLIENT) corpus: 3, objectives: 0, executions: 7593, exec/sec: 0.000,
size_edges: 12/21 (57%), edges_stability: 11/11 (100%), edges: 12/21 (57%)
=================================================================
==9==ERROR: AddressSanitizer: heap-use-after-free on address 0xfcbfab6655c0 at pc 0xffffab9c1888 bp 0xffffee4ce430 sp 0xffffee4ce428
READ of size 1 at 0xfcbfab6655c0 thread T0
 #0 0xffffab9c1884 in _c_dummy_test_one_input /usr/local/bundle/gems/ ruzzy-0.7.0/ext/dummy/dummy.c:18:24
...
Figure 11: Ruzzy fuzzing with LibAFL

This AddressSanitizer output shows that LibAFL starts cleanly and quickly finds the intentional bug in dummy.c. The heap-use-after-free in the dummy C extension confirms the full pipeline is working: instrumentation, coverage tracking, tracing, and crash detection are all functioning as expected.

Try out Ruzzy with LibAFL

We recently released version 0.8.0 of Ruzzy, which includes LibAFL support. Give it a spin on your next Ruby project or audit. I worked with Claude on implementing this improvement, and sometimes it would race so far ahead to the finish line that it would take me two days to catch up. Getting a working implementation is still the end goal, and reverse engineering a patch is a lot easier after it is working, but deeply understanding the patch is valuable too. I learned a lot about ELF binaries, fuzzing engine internals, linkers, and compilers throughout this process. LLMs are a useful tool not only for getting stuff done, but also for understanding the world around us.

If you’d like to read more about fuzzing, check out the following resources:

As always, contact us if you need help with your next Ruby project or fuzzing campaign.

❌