Skip to content

Report what the API actually produced (changes behaviour-log output) - #209

Draft
doomedraven wants to merge 1 commit into
kevoreilly:capemonfrom
doomedraven:fix/log-record-correctness
Draft

doomedraven wants to merge 1 commit into
kevoreilly:capemonfrom
doomedraven:fix/log-record-correctness

Conversation

@doomedraven

Copy link
Copy Markdown
Contributor

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 NDEBUG PR later in the series, which cannot be split: defining NDEBUG deletes the VirtualProtect call inside assert() at alloc.c:85 and the only pre_tramp overflow check at hooking_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:

Handler File What was logged
NtQueryVolumeInformationFile hook_file.c IoStatusBlock->Information
DeviceIoControl hook_misc.c *lpBytesReturned
RtlDecompressBuffer hook_misc.c *FinalUncompressedSize
NtQueryInformationToken hook_process.c TokenInformationLength
NtWow64ReadVirtualMemory64 hook_process.c NumberOfBytesRead->LowPart
NtQueryKey hook_reg_native.c info->KeyNameLength

On failure the output buffer is untouched, so what ends up in the log is whatever was in the caller's memory. NtQueryInformationToken is the worst of these: TokenInformationLength is the caller's capacity, not the written size, so even successful calls logged trailing uninitialised bytes. It now prefers ReturnLength.

2. NCrypt* store a SECURITY_STATUS in a BOOL

BOOL ret = Old_NCryptDecrypt(...);      // SECURITY_STATUS; success is 0
if (ret && g_config.dump_crypto) { ... }
if (ret && g_config.unpacker && ...) { ... }
LOQ_bool("crypto", ...);                 // is_success = (ret != FALSE)

Three handlers do this: NCryptImportKey, NCryptDecrypt, NCryptEncrypt.

Two consequences. is_success is inverted on every one of these records. And for NCryptDecrypt, dump_crypto and unpacker only fired when the decryption had failed — so the feature that exists to dump decrypted payloads was dumping ciphertext, and only on error paths.

BCryptDecrypt has the same inverted test on an NTSTATUS.

3. BCryptDecrypt / NCryptDecrypt log the caller's capacity as the plaintext length

cbOutput is how big the buffer is. pcbResult is 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". I does va_arg(args, int *) and dereferences it under SEH. MemorySize and OptionFlags are values, so the value is reinterpreted as a pointer. Logs 0 or garbage. → i.
  • EnableTraceEx (hook_trace.c) — "ssxixxxi". x does va_arg(args, LARGE_INTEGER); Level is a UCHAR promoted to int. 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) — u is the wide-string specifier; lpString1 is ANSI.
  • FindResourceExW (hook_misc.c) — swprintf_s(type_id, sizeof(type_id), ...) where type_id is wchar_t[8]. swprintf_s counts characters. → _countof.

5. UNICODE_STRING.Length is a byte count, wcsncmp counts characters

!wcsncmp(module_name->Buffer, L"ntdll.dll", module_name->Length)

Three sites: NtProtectVirtualMemory, VirtualProtectEx, RtlDispatchException.

wcsncmp short-circuits on the first difference, so this does not reliably over-read — but that also means the comparison only succeeds if Buffer[9] happens to be L'\0'. UNICODE_STRING is not required to be NUL-terminated. So whether ntdll-protect works at all today depends on what get_module_name leaves 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 the PHANDLE out-parameter, not the handle.
  • DbgUiWaitStateChange — CLIENT_ID.UniqueProcess / .UniqueThread are ids stored in HANDLE-typed fields. Passing them to pid_from_process_handle / tid_from_thread_handle returns 0 for every record, in all three branches of the switch.

7. NtQuerySystemTime returned a literal 0

Every failure was reported to the caller as success.

8. char is signed on MSVC

int utf8_length(unsigned short x);
...
ret += utf8_length(*s++);                                  // utf8.c, s is const char *
pos += utf8_do_encode(*p++, (unsigned char *)&utf8s[pos]); // log.c, p is const char *

Byte 0xE9 sign-extends to -23, converts to unsigned short as 0xFFE9, 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:44 already 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.

  1. is_success flips for NCryptImportKey, NCryptDecrypt, NCryptEncrypt.
  2. Buffer arguments previously logged after a failed call are now empty.
  3. BCryptDecrypt / NCryptDecrypt Output and Length shrink from the caller's capacity to the bytes actually written.
  4. DbgUiWaitStateChange ProcessId / ThreadId become non-zero.
  5. EnableTraceEx arguments after Level become correct on x86.
  6. lstrcpynA String changes from a mis-decoded wide string to the ANSI string.
  7. Non-ASCII bytes in ANSI strings decode correctly instead of becoming U+FFxx.
  8. dump_crypto and unpacker dumps now happen on successful BCryptDecrypt / NCryptDecrypt instead 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 checks reports no checks on any of the branches. Either fork PRs need your approval before workflows run, or .github/workflows/msbuild.yml's runs-on: windows-2019 no longer schedules since GitHub retired that image. Tell me which and I will send a prerequisite PR bumping the runner and the actions/checkout@v3 / upload-artifact@v3 steps.

Series

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
@doomedraven

Copy link
Copy Markdown
Contributor Author

Correction to the CI note in the description above: the diagnosis there was wrong.

The MSBuild workflow is disabled_manually at the repository level, so it cannot run regardless of what the PR contains:

$ gh api repos/kevoreilly/capemon/actions/workflows
28093452  MSBuild  .github/workflows/msbuild.yml  disabled_manually

On top of that it targets the retired windows-2019 image (jobs queue for 24h and get cancelled by the timeout — visible in every PR Build Test run), and all three projects declare PlatformToolset=v141, which is not installed on windows-2022 or windows-2025.

#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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant