{"slug": "c-c-checklist-challenges-solved", "title": "C/C++ checklist challenges, solved", "summary": "Trail of Bits released a C/C++ security checklist and challenged readers to find bugs in a Linux ping program and a Windows driver registry handler, revealing vulnerabilities including a global buffer issue in `inet_ntoa` and a missing `RTL_QUERY_REGISTRY_TYPECHECK` flag. The company also developed a new Claude skill called c-review that converts the checklist into bug-finding prompts for LLMs, enabling automated code analysis across platforms and threat models.", "body_md": "We recently added a [C/C++ security checklist](https://appsec.guide/docs/languages/c-cpp/) to the Testing Handbook and [challenged readers to spot the bugs in two code samples](https://blog.trailofbits.com/2026/04/09/master-c-and-c-with-our-new-testing-handbook-chapter/): a deceptively simple Linux ping program and a Windows driver registry handler. If you found the `inet_ntoa`\n\nglobal buffer gotcha or the missing `RTL_QUERY_REGISTRY_TYPECHECK`\n\nflag, 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.\n\nSince we first released the new C/C++ security checklist, we also developed a new Claude skill, [c-review](https://github.com/trailofbits/skills/tree/main/plugins/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:\n\n```\nclaude skills add-marketplace https://github.com/trailofbits/skills\nclaude skills enable c-review --marketplace trailofbits/skills\n```\n\nThe Linux warmup challenge we showed you in the last blog post has an obvious command injection issue.\n\nThere are three validations that have to be bypassed before the `system`\n\ncall can be reached with malicious inputs:\n\n`inet_aton`\n\nfunction`ntohl`\n\ncall aims to prevent server-side request forgery (SSRF) attacks by disallowing addresses in 127.0.0.0/8 range.`inet_ntoa`\n\ncall and compared against the `ALLOWED_IP`\n\n. 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`\n\nfunction is that it [accepts trailing garbage](https://sourceware.org/bugzilla/show_bug.cgi?id=20018). 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.\n\nThe gotcha with `inet_ntoa`\n\nis that it returns a pointer to a global buffer. Therefore, subsequent calls to the function overwrite previous outputs. In the challenge, `ip_addr_resolved`\n\nand `trusted_resolved`\n\nare the same pointer. When we provide “1.2.3.4” as input, `ip_addr_resolved`\n\npoints to the string “1.2.3.4”, the SSRF check passes, the second call to `inet_ntoa`\n\nmakes the `ip_addr_resolved`\n\npointer point to “127.3.3.1”, and so the `strcmp`\n\ncheck passes too.\n\nThere are a few more functions that return pointers to static buffers; these are documented in the new C/C++ Testing Handbook chapter.\n\nWe showed you this Windows Driver Framework (WDF) request handler from a Windows driver and asked you to spot the bugs.\n\nThe intended behavior of the code is to read some software version information from the registry using the `RtlQueryRegistryValues`\n\nAPI, then select one of two possible callback functions depending on that version information.\n\nThe 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`\n\nparameter of the `RtlQueryRegistryValues`\n\ncall. In this case, `RelativeTo`\n\nis set to `RTL_REGISTRY_ABSOLUTE`\n\n, which means that the path will be treated as an absolute path to a registry key object (e.g., `\\Registry\\User\\CurrentUser`\n\n). There are two main reasons why this is a potential security issue.\n\nFirst, 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.\n\nSecond, 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`\n\n, 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`\n\n(and sometimes also `MinorVersion`\n\n) anywhere in the registry, but this is unlikely to be particularly useful in practice.\n\nThe more serious bugs in this case arise from the flags set in the `RTL_QUERY_REGISTRY_TABLE`\n\nstructs. The `RtlQueryRegistryValues`\n\nAPI 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`\n\nfield of the struct points to a callback function that receives the value read from the registry. In direct mode, the `QueryRoutine`\n\nfield is ignored and the value is instead written directly to a buffer whose location is passed in the `EntryContext`\n\nfield. Direct mode is selected by including `RTL_QUERY_REGISTRY_DIRECT`\n\nin the `Flags`\n\nfield.\n\nIn our example, the `MajorVersion`\n\nvalue is read using the following code:\n\nHere, `RTL_QUERY_REGISTRY_DIRECT`\n\nis used to select direct mode, and the buffer points to `readValue`\n\n, 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`\n\n, but what if the `MajorVersion`\n\nvalue isn’t a `REG_DWORD`\n\n?\n\nLet’s try to exploit this using a `REG_QWORD`\n\n. A `REG_DWORD`\n\nvalue is a 32-bit unsigned integer, whereas a `REG_QWORD`\n\nis a 64-bit unsigned integer, so if we make `MajorVersion`\n\na `REG_QWORD`\n\nvalue instead, then we should be able to overwrite four bytes immediately after `readValue`\n\non the stack. Since `HKEY_CURRENT_USER`\n\nis writable by low-privilege users, we can create a key somewhere in there, place a `REG_QWORD`\n\nvalue called `MajorVersion`\n\nin there, and pass the path of that key to the driver. And success, we get a BSOD!\n\nExcept… it’s not quite what we wanted. The bugcheck code is `KERNEL_SECURITY_CHECK_FAILURE`\n\n, 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](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-rtlqueryregistryvalues):\n\nStarting with Windows 8, if an\n\n`RtlQueryRegistryValues`\n\ncall accesses an untrusted hive, and the caller sets the`RTL_QUERY_REGISTRY_DIRECT`\n\nflag for this call, the caller must additionally set the`RTL_QUERY_REGISTRY_TYPECHECK`\n\nflag. 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`\n\n).Only system hives are trusted. An\n\n`RtlQueryRegistryValues`\n\ncall that accesses a system hive does not cause an exception or a bug check if the`RTL_QUERY_REGISTRY_DIRECT`\n\nflag is set and the`RTL_QUERY_REGISTRY_TYPECHECK`\n\nflag is not set. However, as a best practice, the`RTL_QUERY_REGISTRY_TYPECHECK`\n\nflag should always be set if the`RTL_QUERY_REGISTRY_DIRECT`\n\nflag is set.Similarly, in versions of Windows before Windows 8, as a best practice, an\n\n`RtlQueryRegistryValues`\n\ncall that sets the`RTL_QUERY_REGISTRY_DIRECT`\n\nflag should additionally set the`RTL_QUERY_REGISTRY_TYPECHECK`\n\nflag. 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.\n\nTo summarize, if you try to read from an untrusted registry hive using `RtlQueryRegistryValues`\n\nwith `RTL_QUERY_REGISTRY_DIRECT`\n\nset but without also setting `RTL_QUERY_REGISTRY_TYPECHECK`\n\n, then Windows will automatically raise a bugcheck to crash the system and prevent the operation from succeeding.\n\nThe `RTL_QUERY_REGISTRY_TYPECHECK`\n\nflag 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:\n\n`\\REGISTRY\\MACHINE\\HARDWARE`\n\n`\\REGISTRY\\MACHINE\\SOFTWARE`\n\n`\\REGISTRY\\MACHINE\\SYSTEM`\n\n`\\REGISTRY\\MACHINE\\SECURITY`\n\n`\\REGISTRY\\MACHINE\\SAM`\n\n`HKEY_CURRENT_USER`\n\nis not included within this set, which explains why we saw the `KERNEL_SECURITY_CHECK_FAILURE`\n\nbugcheck 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.\n\nHowever, 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](https://www.powershellgallery.com/packages/NtObjectManager/) has a command named `Get-AccessibleKey`\n\nthat is perfect for the task:\n\n`Get-AccessibleKey \\Registry\\Machine -Recurse -Access SetValue`\n\nThis command searches recursively within the `\\Registry\\Machine`\n\nobject 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.\n\nHowever, for style points, we can go one step further. [Mandatory integrity control (MIC)](https://learn.microsoft.com/en-us/windows/win32/secauthz/mandatory-integrity-control), 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?\n\nTo 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:\n\n`$token = Get-NtToken -Primary -Duplicate -IntegrityLevel Low`\n\nThis 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:\n\n`Get-AccessibleKey \\Registry\\Machine -Recurse -Access SetValue -Token $token`\n\nThis does actually return a few results, on both Windows 10 and 11. Here are two of the most interesting:\n\n`\\REGISTRY\\MACHINE\\SOFTWARE\\Microsoft\\DRM`\n\n`\\REGISTRY\\MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\PlayReady\\Troubleshooter`\n\nBoth of these keys allow a low-integrity token to write to them. The `DRM`\n\nkey’s DACL has fairly complex permissions applied but grants the Set Value permission to the Everyone group. The `PlayReady\\Troubleshooter`\n\nkey’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.\n\n(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.)\n\nIf we set a `REG_QWORD`\n\nvalue called `MajorVersion`\n\nin the `DRM`\n\nkey, then pass that key’s path to the WDF handler, we can now overwrite four bytes of stack past the end of `readValue`\n\nwith values that we control. Since `handlerCallback`\n\nwas declared adjacent to `readValue`\n\n, 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`\n\nvariable ended up 64-bit aligned, leaving a gap, so this approach may not get us far in practice.\n\nCan we do better?\n\nOk, so far we’ve only explored what happens when we exploit the type confusion with `REG_QWORD`\n\n, but what happens if we use `REG_SZ`\n\n?\n\nIn the case of `REG_SZ`\n\n(i.e., a string value), the documentation says the following about `RtlQueryRegistryValues`\n\n’ behavior in direct mode:\n\nA null-terminated Unicode string (such as\n\n`REG_SZ`\n\n,`REG_EXPAND_SZ`\n\n):`EntryContext`\n\nmust point to an initialized`UNICODE_STRING`\n\nstructure. If the`Buffer`\n\nmember of`UNICODE_STRING`\n\nis NULL, the routine allocates storage for the string data. Otherwise, it stores the string data in the buffer that`Buffer`\n\npoints to.\n\nLet’s try exploiting this. `RtlQueryRegistryValues`\n\nwill interpret the `EntryContext`\n\nfield as if it were a `UNICODE_STRING`\n\nstruct, but it’s actually pointing at `readValue`\n\n, which is an `int`\n\n. Here’s what a `UNICODE_STRING`\n\nlooks like:\n\nIn the first call that the code makes to `RtlQueryRegistryValues`\n\n, when reading `MajorVersion`\n\n, the value of `readValue`\n\nhas been initialized to zero. Since `readValue`\n\nis four bytes and a `USHORT`\n\nis two bytes, interpreting `readValue`\n\nas a `UNICODE_STRING`\n\nat that time will result in both `Length`\n\nand `MaximumLength`\n\nbeing zero and `Buffer`\n\ncontaining whatever’s immediately after `readValue`\n\nin the stack. Since the length of the buffer is zero, `RtlQueryRegistryValues`\n\nwill just return `STATUS_BUFFER_TOO_SMALL`\n\nand not attempt to write to the `Buffer`\n\nfield.\n\nHowever, let’s take a look at the second call to `RtlQueryRegistryValues`\n\n:\n\nThis part of the code first checks if the `MajorVersion`\n\nvalue is less than three and, if so, reads the `MinorVersion`\n\nvalue using the same approach as before. A key observation here is that `readValue`\n\nis not reinitialized between the calls. This gives us some extra control: by leaving `MajorVersion`\n\nas a `REG_DWORD`\n\n, as originally intended by the code, we can have the first `RtlQueryRegistryValues`\n\ncall load a value into `readValue`\n\n. Then, when the second call to `RtlQueryRegistryValues`\n\nis made, to read `MinorVersion`\n\n, we control the first four bytes of data pointed to by `EntryContext`\n\n. If `MinorVersion`\n\nis a `REG_SZ`\n\nvalue, a type confusion occurs where `RtlQueryRegistryValues`\n\nexpects `EntryContext`\n\nto point to a `UNICODE_STRING`\n\n, causing the contents of the `MajorVersion`\n\ninteger to be reinterpreted as the `Length`\n\nand `MaximumLength`\n\nfields. The only restriction is that we need the major version check to pass (i.e., `version.Major`\n\nmust 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`\n\nvalue to `0xF000F002`\n\n, the code will interpret this as `-268374014`\n\nbecause `readValue`\n\nis a signed 32-bit integer. The `Length`\n\nand `MaximumLength`\n\nfields, however, are unsigned 16-bit integers, causing the `0xF000F002`\n\nvalue to get interpreted as the following when type confused as a `UNICODE_STRING`\n\n:\n\nThe `Buffer`\n\nfield ends up pointing at whatever’s next in the stack. If we combine this current approach with the `REG_QWORD`\n\ntrick from before, we can also overwrite four bytes of the `Buffer`\n\npointer during the `MajorVersion`\n\nread. 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!\n\nBut can we do *even better*?\n\nLet’s take a look at what happens if we try a `REG_BINARY`\n\nvalue instead. Here’s what the documentation has to say about such values in direct mode:\n\nNonstring data with size, in bytes, greater than\n\n`sizeof(ULONG)`\n\n: The buffer pointed to by`EntryContext`\n\nmust begin with a signed`LONG`\n\nvalue. The magnitude of the value must specify the size, in bytes, of the buffer. If the sign of the value is negative,`RtlQueryRegistryValues`\n\nwill only store the data of the key value. Otherwise, it will use the first`ULONG`\n\nin the buffer to record the value length, in bytes, the second`ULONG`\n\nto record the value type, and the rest of the buffer to store the value data.\n\nThis 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`\n\nis expected to be prefilled with a signed `LONG`\n\nvalue that tells `RtlQueryRegistryValues`\n\nhow large the buffer is. A `LONG`\n\nis just a 32-bit integer, so a signed `LONG`\n\nis functionally equivalent to `int`\n\nfor 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`\n\ndata directly into the buffer pointed to by `EntryContext`\n\n. If the value is positive, it will first write the length of the `REG_BINARY`\n\ndata into the first `ULONG`\n\nof the buffer, then it will write the `REG_BINARY`\n\ntype value into the second `ULONG`\n\nof the buffer, and finally it will copy the `REG_BINARY`\n\ndata into the remainder of the buffer.\n\nYou may have figured out the exploit already here. The `MinorVersion`\n\nregistry value is only read when the `MajorVersion`\n\nis less than 3. If we set `MajorVersion`\n\nto some negative number, this check will pass. This negative number ends up left in `readValue`\n\nfor the second `RtlQueryRegistryValues`\n\ncall. If the `MinorVersion`\n\nvalue is a `REG_BINARY`\n\n, `RtlQueryRegistryValues`\n\ntreats the first `ULONG`\n\nin the “buffer” as being the signed length field. Since our “buffer” is just whatever was in `readValue`\n\nfrom the previous call, this causes `RtlQueryRegistryValues`\n\nto copy the contents of the registry value into the “buffer,” which is really just stack memory starting at `readBytes`\n\n. 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`\n\nvalue can be anything we like, it means we control what is overwritten.\n\nFor example, if we create a `REG_DWORD`\n\nvalue called `MajorVersion`\n\nwith a value of `0xFFFFFFF4`\n\n, then create a `REG_BINARY`\n\nvalue called `MinorVersion`\n\nwith a value of `00 00 00 00 DE AD BE EF DE AD BE EF`\n\n, this causes the first `RtlQueryRegistryValues`\n\ncall to fill `readValue`\n\nwith -12, which the second `RtlQueryRegistryValues`\n\ncall interprets as a 12-byte buffer where only the binary should be copied. This results in `RtlQueryRegistryValues`\n\ncopying `00 00 00 00`\n\ninto `readValue`\n\n, then writing `DE AD BE EF DE AD BE EF`\n\nonto the stack afterwards. Assuming that the `handlerCallback`\n\nfunction pointer is stored after the `readValue`\n\nvariable 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.\n\nBut can we do *even better still*? If you think you can, get in touch! We’d love to hear your tips and tricks.\n\nThese challenges only scratch the surface of what the [C/C++ Testing Handbook chapter](https://appsec.guide/docs/languages/c-cpp/) 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](https://github.com/trailofbits/skills/tree/main/plugins/c-review), 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](https://www.trailofbits.com/contact/).", "url": "https://wpnews.pro/news/c-c-checklist-challenges-solved", "canonical_source": "https://blog.trailofbits.com/2026/05/05/c/c-checklist-challenges-solved/", "published_at": "2026-05-05 11:00:00+00:00", "updated_at": "2026-06-03 13:09:59.485815+00:00", "lang": "en", "topics": ["ai-tools", "large-language-models", "ai-products", "ai-research"], "entities": ["Trail of Bits", "Claude", "c-review", "Testing Handbook"], "alternates": {"html": "https://wpnews.pro/news/c-c-checklist-challenges-solved", "markdown": "https://wpnews.pro/news/c-c-checklist-challenges-solved.md", "text": "https://wpnews.pro/news/c-c-checklist-challenges-solved.txt", "jsonld": "https://wpnews.pro/news/c-c-checklist-challenges-solved.jsonld"}}