Report what the API actually produced (changes behaviour-log output) - #209
Draft
doomedraven wants to merge 1 commit into
Draft
doomedraven wants to merge 1 commit into
doomedraven wants to merge 1 commit into
Conversation
The behaviour log currently contains values no API ever returned:
lengths taken from the caller's capacity instead of the written size,
buffers logged after calls that failed and wrote nothing, success flags
computed from the wrong sign convention, and format specifiers that do
not match the argument they consume.
WARNING: this changes the contents of the behaviour log. See the list of
observable differences at the end.
Lengths consumed regardless of whether the call succeeded
NtQueryVolumeInformationFile (hook_file.c), DeviceIoControl and
RtlDecompressBuffer (hook_misc.c), NtQueryInformationToken and
NtWow64ReadVirtualMemory64 (hook_process.c), NtQueryKey
(hook_reg_native.c). On failure the output buffer is not written, so
these logged whatever the caller's memory happened to contain.
NtQueryInformationToken additionally used TokenInformationLength - the
caller's capacity - rather than ReturnLength.
NCrypt* store a SECURITY_STATUS in a BOOL
NCryptImportKey, NCryptDecrypt and NCryptEncrypt declare
`BOOL ret = Old_NCrypt*(...)` and then use LOQ_bool, whose predicate is
`ret != FALSE`. SECURITY_STATUS success is ERROR_SUCCESS, i.e. 0, so
is_success was inverted on every one of these records. NCryptDecrypt
also gates both of its dump paths on `if (ret && ...)`, so
dump_crypto and unpacker only fired when the decryption had failed.
Now SECURITY_STATUS with LOQ_zero.
BCryptDecrypt has the same inverted test
`ret` is an NTSTATUS. `if (ret && g_config.dump_crypto)` and
`if (ret && g_config.unpacker && ...)` both ran only on failure.
BCryptDecrypt / NCryptDecrypt log the wrong output length
cbOutput is the caller's buffer capacity; pcbResult is the number of
bytes written. The difference is logged as plaintext.
Format specifiers that do not match their argument
- SystemFunction040/041 (hook_crypto.c): 'I' dereferences an int *,
but MemorySize and OptionFlags are values. Changed to 'i'.
- EnableTraceEx (hook_trace.c): 'x' consumes a LARGE_INTEGER, but
Level is a UCHAR promoted to int. On x86 this desynchronises the
va_list for every remaining argument in the record.
- lstrcpynA (hook_misc.c): 'u' is the wide-string specifier; the
argument is ANSI.
- FindResourceExW (hook_misc.c): swprintf_s counts characters, not
bytes, so sizeof() overstated an 8-wchar buffer by 2x.
UNICODE_STRING.Length is a byte count
NtProtectVirtualMemory, VirtualProtectEx and RtlDispatchException pass
it to wcsncmp, which counts characters. The comparison then runs past
the end of the name and into whatever follows it. Whether ntdll_protect
works at all currently depends on whether the buffer returned by
get_module_name happens to be NUL-terminated.
Pointer/handle confusion
- CreateThread (hook_thread.c): GetExportNameByAddress(*lpStartAddress)
dereferences the thread entry point's first bytes as a pointer. The
line above it correctly uses lpStartAddress.
- RtlCreateUserThread (hook_thread.c): tid_from_thread_handle() is
handed the PHANDLE out-parameter instead of the handle.
- DbgUiWaitStateChange (hook_process.c): CLIENT_ID members are ids
stored in HANDLE-typed fields. Resolving them with
pid_from_process_handle/tid_from_thread_handle returned 0 for every
record.
NtQuerySystemTime swallowed its status
hook_sleep.c returned a literal 0, reporting every failure as success.
char is signed on MSVC
utf8_strlen_ascii (utf8.c) and the encode loop in log_string (log.c)
pass a plain char to a routine taking unsigned short. Byte 0xE9
sign-extends to -23 and is encoded as U+FFE9 instead of U+00E9, so
every high-bit byte in an ANSI string is corrupted in the log. Both
the sizing pass and the encoding pass are changed together; they must
agree or the buffer arithmetic breaks. pipe.c:44 already does this
correctly.
Observable differences in the behaviour log
- is_success flips for NCryptImportKey, NCryptDecrypt, NCryptEncrypt.
- Buffer arguments that were previously logged after a failed call are
now empty.
- BCryptDecrypt/NCryptDecrypt Output and Length shrink from the
caller's capacity to the number of bytes written.
- DbgUiWaitStateChange ProcessId/ThreadId become non-zero.
- EnableTraceEx arguments after Level become correct on x86.
- lstrcpynA String changes from a mis-decoded wide string to ANSI.
- Non-ASCII bytes in ANSI strings decode correctly.
- dump_crypto and unpacker dumps now happen on successful BCryptDecrypt
and NCryptDecrypt rather than on failed ones.
TAG=agy
CONV=b3280e17-abe0-4fed-ad0b-c2e7f65da90f
This was referenced Sep 15, 2026
Contributor
Author
|
Correction to the CI note in the description above: the diagnosis there was wrong. The MSBuild workflow is On top of that it targets the retired #212 fixes the workflow file for all three. Re-enabling the workflow in Settings → Actions is the part that has to be done by hand. |
This was referenced Sep 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Fourth PR in the review series.
Warning
This one changes what the behaviour log contains. The previous three were memory safety with no intended effect on output. This PR corrects records that report values no API ever produced, which means existing CAPEv2 signatures that match on those values will see different data. The full list of observable differences is at the bottom. Please read it before merging — this is the PR that needs a decision from you, not just a review.
Are these related?
Independent of each other, same as #206–#208. Batched because they share one theme and one validation question: does the monitor report what the API actually did?
Still contrasting with the
NDEBUGPR later in the series, which cannot be split: definingNDEBUGdeletes theVirtualProtectcall insideassert()atalloc.c:85and the onlypre_trampoverflow check athooking_32.c:267,378/hooking_64.c:542,826. Those must land in the same commit as the define.The fixes
1. Output lengths consumed regardless of whether the call succeeded
Seven handlers log an output buffer using a length that is valid only on success:
NtQueryVolumeInformationFilehook_file.cIoStatusBlock->InformationDeviceIoControlhook_misc.c*lpBytesReturnedRtlDecompressBufferhook_misc.c*FinalUncompressedSizeNtQueryInformationTokenhook_process.cTokenInformationLengthNtWow64ReadVirtualMemory64hook_process.cNumberOfBytesRead->LowPartNtQueryKeyhook_reg_native.cinfo->KeyNameLengthOn failure the output buffer is untouched, so what ends up in the log is whatever was in the caller's memory.
NtQueryInformationTokenis the worst of these:TokenInformationLengthis the caller's capacity, not the written size, so even successful calls logged trailing uninitialised bytes. It now prefersReturnLength.2.
NCrypt*store aSECURITY_STATUSin aBOOLThree handlers do this:
NCryptImportKey,NCryptDecrypt,NCryptEncrypt.Two consequences.
is_successis inverted on every one of these records. And forNCryptDecrypt,dump_cryptoandunpackeronly fired when the decryption had failed — so the feature that exists to dump decrypted payloads was dumping ciphertext, and only on error paths.BCryptDecrypthas the same inverted test on anNTSTATUS.3.
BCryptDecrypt/NCryptDecryptlog the caller's capacity as the plaintext lengthcbOutputis how big the buffer is.pcbResultis how much was written. The gap is logged as if it were decrypted data.4. Format specifiers that do not match their argument
loq()'s format letters are typed. Four sites disagree with what they pass:SystemFunction040/041(hook_crypto.c) —"pbII".Idoesva_arg(args, int *)and dereferences it under SEH.MemorySizeandOptionFlagsare values, so the value is reinterpreted as a pointer. Logs 0 or garbage. →i.EnableTraceEx(hook_trace.c) —"ssxixxxi".xdoesva_arg(args, LARGE_INTEGER);Levelis aUCHARpromoted toint. On x86 varargs that is a 4-byte slot read as 8, which desynchronises every remaining argument in the record. →"ssxiixxi".lstrcpynA(hook_misc.c) —uis the wide-string specifier;lpString1is ANSI.FindResourceExW(hook_misc.c) —swprintf_s(type_id, sizeof(type_id), ...)wheretype_idiswchar_t[8].swprintf_scounts characters. →_countof.5.
UNICODE_STRING.Lengthis a byte count,wcsncmpcounts charactersThree sites:
NtProtectVirtualMemory,VirtualProtectEx,RtlDispatchException.wcsncmpshort-circuits on the first difference, so this does not reliably over-read — but that also means the comparison only succeeds ifBuffer[9]happens to beL'\0'.UNICODE_STRINGis not required to be NUL-terminated. So whetherntdll-protectworks at all today depends on whatget_module_nameleaves after the name.Note
Two of these three sites are three lines away from the parenthesisation fix in #206 (
if (module_name && a || b)). Whichever of the two merges second will need a trivial rebase.6. Pointer/handle confusion
CreateThread—GetExportNameByAddress((PVOID)*lpStartAddress)dereferences the thread entry point's first bytes and uses them as an address. The line directly above correctly uses(ULONG_PTR)lpStartAddress.RtlCreateUserThread—tid_from_thread_handle(ThreadHandle)is handed thePHANDLEout-parameter, not the handle.DbgUiWaitStateChange—CLIENT_ID.UniqueProcess/.UniqueThreadare ids stored inHANDLE-typed fields. Passing them topid_from_process_handle/tid_from_thread_handlereturns 0 for every record, in all three branches of the switch.7.
NtQuerySystemTimereturned a literal0Every failure was reported to the caller as success.
8.
charis signed on MSVCByte
0xE9sign-extends to-23, converts tounsigned shortas0xFFE9, and is encoded as U+FFE9 — 3 bytes instead of 2. Every high-bit byte in an ANSI string is corrupted in the BSON log.pipe.c:44already casts correctly.Both the sizing pass (
utf8_strlen_ascii) and the encoding pass are changed together — they must agree or the buffer arithmetic breaks.Observable differences in the behaviour log
This is the list to check against the CAPEv2 side.
is_successflips forNCryptImportKey,NCryptDecrypt,NCryptEncrypt.BCryptDecrypt/NCryptDecryptOutputandLengthshrink from the caller's capacity to the bytes actually written.DbgUiWaitStateChangeProcessId/ThreadIdbecome non-zero.EnableTraceExarguments afterLevelbecome correct on x86.lstrcpynAStringchanges from a mis-decoded wide string to the ANSI string.dump_cryptoandunpackerdumps now happen on successfulBCryptDecrypt/NCryptDecryptinstead of failed ones. This is the change most likely to alter what gets extracted from a sample.If any of these would break an existing signature, say which and I will split it out or gate it.
Not verified by build
No MSVC here. CI has not run on #206, #207 or #208 —
gh pr checksreports no checks on any of the branches. Either fork PRs need your approval before workflows run, or.github/workflows/msbuild.yml'sruns-on: windows-2019no longer schedules since GitHub retired that image. Tell me which and I will send a prerequisite PR bumping the runner and theactions/checkout@v3/upload-artifact@v3steps.Series
inside_hookranges,lookupbuckets, single backtrace walk)NDEBUG+ assert conversion +/O2 /Oy-, the coupled one)hook_apilock, arena free list, breakpoint lists)