Egy Windows driver regisztrációs hibát egy mag-írás alapvető hibává emel
CYBERSECURITY KIEMELT ELEMZÉS

Egy Windows driver regisztrációs hibát egy mag-írás alapvető hibává emel

FORRÁS

The Trail of Bits Blog

DATE

READ

8 perc olvasás

A teszt útmutató biztonsági ellenőrző listát C/C++-hoz, valamint kihívásokat a Linux ping program és a Windows driver regisztrációs kezelővel kapcsolatban tartalmaz, melyekben olvasók felhívást kaptak, hogy az olyan …

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 #include #include #include #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: 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. The ntohl call aims to prevent server-side request forgery (SSRF) attacks by disallowing addresses in 127.0.0.0/8 range. 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, ®Path, &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 = LMajorVersion; 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 = LMinorVersion; 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: 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 = LMinorVersion; 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. Since the length of the buffer is zero, interpreting readValue as a UNICODE_STRING at that time will result in both Length and MaximumLength being zero and Buffer containing whatever is next in the stack. Since the contents of the buffer are 00 00 00 00, this causes the following: USHORT Length = 00 00; USHORT MaximumLength = 00 00; PWSTR Buffer = ????????; Now, if we overwrite the contents of the MinorVersion value (say, with 00 00 00 00 DE AD BE EF DE AD BE EF), and set version.Minor = 00 00 00 00, we can now overwrite the contents of the Buffer pointer. Since this is a 4-byte overwrite, we can control what is written. Since we can control the content of the buffer, we can overwrite the contents of the Buffer pointer. This gives us a semi-controlled write-what-where primitive in the kernel. But can we do even better? 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.