Skip to content

nanopb: pluggable logging strategy (BSON default, protobuf opt-in) - #164

Draft
doomedraven wants to merge 2 commits into
kevoreilly:capemonfrom
doomedraven:opt/pluggable-serialization
Draft

doomedraven wants to merge 2 commits into
kevoreilly:capemonfrom
doomedraven:opt/pluggable-serialization

Conversation

@doomedraven

@doomedraven doomedraven commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Stacked on #215, which owns the thread-local logging context. Merge that first; this branch contains it as its parent commit, so the diff GitHub shows here will collapse to just the serializer work once #215 lands.

TLDR

  • log-format = 0 (default) — BSON, byte-for-byte the same output as today.
  • log-format = 1 — Protocol Buffers, experimental.

log.c's formatting loops no longer call BSON directly. They go through a log_serializer_t vtable held in the per-thread log context, so the wire format becomes a runtime choice instead of a compile-time one. Nothing downstream changes unless log-format is set.

1. Impact on custom agents and result servers

"Could these changes potentially have impact on anyone using a custom agent or custom result server?"

Only if you opt in. #118 removed BSON outright in favour of nanopb, which would have broken every result server and analysis agent expecting BSON frames. Here BSON stays the default and the strategy is selected at log_init().

The schema question is real either way: BSON is self-describing, so a hook can append arbitrary keys at runtime. Protocol Buffers need a compile-time schema.proto, so a new hooked field means recompiling and deploying capemon and the host-side decoder together.

2. Defects fixed in #118's nanopb wrapper

Wide-string use-after-free. log_wstring converted to UTF-8 on the heap, registered the pointer with the nanopb callback, then freed it immediately. nanopb only serializes at protobuf_finish, at the end of loq. Reading the freed block was an access violation. Fixed with a thread-local bump-allocated scratch pad in protobuf_context_t; strings and binary buffers are copied into it and stay alive until protobuf_finish.

Silent payload drops. The nanopb output stream was a static 4 KB array, so any log over 4 KB — decrypted payloads, network buffers — failed pb_encode and vanished. Buffer is now 64 KB, in the thread-local context rather than on the stack.

3. What changed in this revision

Rebased onto #215 and reduced to the serializer work. Previous revision's problems:

The TEB NtTib.ArbitraryUserPointer slot is not free. ntdll's loader parks the FullDllName pointer there across its NtMapViewOfSection call so the debugger can see the module being mapped, and capemon hooks NtMapViewOfSection (hooks.c:127, :975, :1065, :1358):

ntdll!LdrpMapViewOfSection
  saves ArbitraryUserPointer
  writes &FullDllName into it
  calls NtMapViewOfSection  ->  capemon hook  ->  loq()
                                                    reads ArbitraryUserPointer
                                                    gets a PWSTR
                                                    writes serializer state over the loader's string

That is memory corruption on every module load. The context now comes from dynamic TLS (TlsAlloc/TlsGetValue), in #215.

TlsThreadCleanup() never ran. It was wired to DLL_THREAD_DETACH, but capemon.c:632 calls hide_module_from_peb() during DLL_PROCESS_ATTACH and misc.c:1072-1095 unlinks the module from all three loader lists. The loader walks those to dispatch thread notifications, so DllMain is never entered again. Both the hook and the function are gone; contexts are per-thread and die with the process.

The lookup_t keyed by thread id is gone with it. TlsGetValue answers the same question without a list walk.

All three g_mutex acquisitions go through loq_lock(), which keeps the fast-path-then-bounded-spin shape from ada31ca rather than dropping straight into the 100-iteration spin.

Both serializer vtables use positional initializers. C99 designated initializers are not accepted by PlatformToolset v141 in C mode, which is what capemon.vcxproj targets. The previous revision would not have compiled on VS2017 even after the finish/append_finish fix.

The .github/workflows/ changes are dropped. #212 owns CI. The pr-build-test.yml copy carried on this branch targeted windows-2019, which is no longer a runner image — every run on it queued for 24h and was cancelled without being scheduled.

capemon.vcxproj now carries only the five ClCompile and seven ClInclude additions; the delay-load settings from 321a8e0 are preserved.

4. Protobuf backend status

Experimental and lossy. schema.proto cannot represent capemon's call model yet — heterogeneous indexed arguments, nested %a arrays, the caller C address, the thread id — and no host-side parser consumes the output. log_init() emits a CRITICAL: notice over the pipe when log-format=1 is set. announce_netlog() still announces BSON; a real protobuf transport needs its own header and a matching reader.

The per-index "explain" frame is BSON-only and is skipped in protobuf mode, so the stream is never a mix of BSON and protobuf frames. The lastlog dedup is likewise BSON-only — it depends on byte-comparable frames with a repeated counter at a fixed offset.

5. Testing

Not compiled and not run. No MSVC available, and the MSBuild workflow is disabled_manually at the repo level, so nothing in this series has been through a compiler. Re-enabling needs Settings → Actions; #212 fixes the workflow file but cannot flip that switch.

tests/test-pluggable-serialization.c is included but has not been built.

6. Are these related?

This one genuinely is coupled: it cannot be reviewed or merged without #215, because the serializer pointer lives in the context #215 introduces. That is the opposite of the independent batch (#206#210, #213, #214), and similar in kind to the NDEBUG case in #211 where the assert removals are only correct because the same PR defines NDEBUG.

#162 is closed — it was the first two commits of this branch, and it could not compile (GNU statement expressions in log.c).

Known textual conflicts in log.c, both trivial rebases: #208 (logtbl_explained bounds — this branch also reads logtbl_explained[index] with an unbounded index) and #209 (log_string/log_wstring).

Series

#206, #207, #208, #209, #210, #211, #212, #213, #214, #215, this one.

@doomedraven doomedraven changed the title Implement Approach A: Pluggable Logging Strategy Pattern (BSON & Protobuf) nanopb: Pluggable Logging Strategy Pattern (BSON & Protobuf) Aug 17, 2026
@rkoumis

rkoumis commented Aug 18, 2026

Copy link
Copy Markdown

This is great, thank you! Much appreciated.

I'm probably missing something super obvious, but I can't figure it out. I noticed that in the definition of loq() there are still plenty of calls to the bson serializing functions, not the active_serializer functions. Just in the bit guarded by if (logtbl_explained[index] == 0) {

Maybe it's worth adding a comment explaining logtbl_explained - ?

@doomedraven
doomedraven force-pushed the opt/pluggable-serialization branch from 54eedce to 2a735de Compare August 18, 2026 13:34
@doomedraven

Copy link
Copy Markdown
Contributor Author

added comment in code

/* logtbl_explained Optimization (BSON Specific):
   The very first time a hooked API index is logged, capemon outputs a schema "explanation"
   to help the legacy BSON log-server parse dynamic argument layouts, then sets logtbl_explained[index] = 1.
   Subsequent calls only log argument values, drastically reducing redundant traffic.
   Note: This block calls BSON-serialization functions directly because modern Protocol Buffers (nanopb)
   utilizes a statically compiled message schema (schema.proto) and has no need for runtime dynamic schemas.
*/
if (logtbl_explained[index] == 0) {

@rkoumis

rkoumis commented Aug 18, 2026

Copy link
Copy Markdown

added comment in code

/* logtbl_explained Optimization (BSON Specific): */

ohhhh I see. Thank you!

@kevoreilly

Copy link
Copy Markdown
Owner

very nice ❤️

I'm low on time today to look into this, so will pick up again tomorrow, but just to note it's currently not compiling:

1>d:\work\cape\capemon\capemon26\log.c(1162): error C2039: 'finish': is not a member of '_log_serializer_t'
1>d:\work\cape\capemon\capemon26\log_serializer.h(12): note: see declaration of '_log_serializer_t'
1>d:\work\cape\capemon\capemon26\log.c(1202): warning C4267: '=': conversion from 'size_t' to 'unsigned int', possible loss of data
1>d:\work\cape\capemon\capemon26\log.c(1187): warning C4267: 'initializing': conversion from 'size_t' to 'unsigned int', possible loss of data

@doomedraven

doomedraven commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

The Root Cause & Technical Fixes Applied:

  1. Resolved Error C2039: 'finish' is not a member of '_log_serializer_t' (Line 1162)
  • The Bug: The code inside loq() was calling g_active_serializer->finish(). However, the struct interface in log_serializer.h defines the member as append_finish.
  • The Fix: corrected the call to call the proper interface member:

1 g_active_serializer->append_finish_array();
2 g_active_serializer->append_finish(); // <-- Corrected member call

  1. Resolved Warnings C4267: '=': conversion from 'size_t' to 'unsigned int', possible loss of data (Lines 1187 & 1202)
  • The Bug: Under 64-bit Windows, size_t is 64-bit (unsigned __int64), while unsigned int is 32-bit. In loq(), our_len and lastlog.len are 32-bit unsigned integers, and assigning the result of g_active_serializer->get_size() to them triggered a compilation warning.
  • The Fix: Added clean, explicit casts to prevent 64-bit pointer truncation warnings:

1 unsigned int our_len = (unsigned int)(g_active_serializer->get_size() - compare_offset);
2 // ...
3 lastlog.len = (unsigned int)g_active_serializer->get_size();


@doomedraven
doomedraven force-pushed the opt/pluggable-serialization branch 3 times, most recently from 0181748 to c3925c6 Compare August 20, 2026 06:56
doomedraven added a commit to doomedraven/capemon that referenced this pull request Aug 20, 2026
Test coverage:
- BSON serialization (default mode)
- Protobuf serialization (opt-in mode)
- Runtime serializer switching
- Thread-local serializer isolation (16 threads)
- Concurrent mixed serializers (8 threads, BSON + Protobuf)
- NULL safety in serializer access

Verifies:
1. Strategy pattern implementation
2. Thread-safe serializer switching
3. Independent per-thread serializer contexts
4. Graceful fallback on NULL
5. No interference between BSON and Protobuf modes

Run with: cd tests && make test-pluggable-serialization.exe && ./test-pluggable-serialization.exe

@doomedraven doomedraven left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I have successfully submitted a structural refactor to the backend code merging the protobuf state via standard TlsAlloc() memory architecture mapping instead of utilizing unallocated __declspec(thread) structures to fix the structural crash bugs observed in native DLL imports. Please review the updated branch.

…all of loq()

`g_bson` and `g_istr` were process-wide statics, so `loq()` had to hold
`g_mutex` from the moment it was entered until the record was flushed. Every
hooked API call on every thread serialized against every other one, and the
lock covered the expensive part (format parsing, string conversion, BSON
building) as well as the cheap part.

This moves the serialization state into a per-thread context and narrows the
lock to the two regions that actually touch shared state:

  * region A - `logtbl_explained`, `last_api_logged`, `lastlog` reset, and the
    one-time schema "explanation" record;
  * region B - the flush into the output buffer and the `lastlog` dedup slot.

Everything in between (the sizing pass, the emit pass, `log_string`,
`log_wstring`, `log_buffer`, ...) now runs unlocked against thread-local
memory.

How the context is reached, and three things this deliberately does not do:

  * Not `__declspec(thread)`. Static TLS is resolved by the loader. It happens
    to work when capemon is injected with LoadLibrary, which is the normal
    path, but `ReflectiveInjectDllViaThread()` in loader/loader/Loader.c maps
    the image by hand and nothing processes the TLS directory there.

  * Not the TEB `NtTib.ArbitraryUserPointer` slot. It is not free: ntdll's
    loader parks the `FullDllName` pointer there across its
    `NtMapViewOfSection` call so the debugger can see the module name.
    capemon hooks `NtMapViewOfSection` (hooks.c:127 and three more places), so
    a hook firing during a module load would read a `PWSTR` out of that slot
    and write BSON state over the loader's string.

  * Not a `DLL_THREAD_DETACH` destructor. capemon.c calls
    `hide_module_from_peb()` during `DLL_PROCESS_ATTACH`, and
    misc.c:1072-1095 unlinks the module from InLoadOrderModuleList,
    InInitializationOrderModuleList, InMemoryOrderModuleList and the hash
    table. The loader walks those lists to dispatch thread notifications, so
    DllMain is never entered again and a TLS callback would never fire.

The context is therefore allocated once per logging thread and never
reclaimed. That is intentional: it is about 40 bytes, the BSON payload itself
is still allocated and released per call by `bson_init`/`bson_destroy`, and
freeing contexts at teardown would race with threads still inside `loq()`.

The accessor macros are plain parenthesised expressions. GNU statement
expressions (`({ ... })`) are not accepted by cl.exe.

The bounded-spin lock acquisition from ada31ca is preserved verbatim, just
factored into `loq_lock()` so both regions use the same shape: one cheap
`TryEnterCriticalSection`, then up to 100 `SwitchToThread` retries, then drop
the record rather than stall a hooked API.

TAG=agy
CONV=b3280e17-abe0-4fed-ad0b-c2e7f65da90f
…mat=1

Rebased onto kevoreilly#215, which owns the thread-local logging context. What is left
here is the serializer abstraction and the nanopb backend.

`log.c`'s formatting loops no longer talk to BSON directly. They go through a
`log_serializer_t` vtable held in the per-thread log context, so the wire
format is a runtime choice:

  log-format = 0   BSON (default, unchanged bytes on the wire)
  log-format = 1   Protocol Buffers (experimental)

Existing result servers and custom agents see exactly what they saw before
unless `log-format` is set, so nothing downstream has to change.

Relative to the previous revision of this branch:

  * The context is reached through dynamic TLS instead of the TEB
    `NtTib.ArbitraryUserPointer` slot. That slot is not free: ntdll's loader
    parks the `FullDllName` pointer there across its `NtMapViewOfSection`
    call, and capemon hooks `NtMapViewOfSection`, so a hook firing during a
    module load read a `PWSTR` out of the slot and wrote serializer state over
    the loader's string.

  * `TlsThreadCleanup()` and the `DLL_THREAD_DETACH` hook in capemon.c are
    gone. `hide_module_from_peb()` unlinks the module from the loader lists
    during `DLL_PROCESS_ATTACH`, so DllMain is never entered again and that
    cleanup never ran.

  * The `lookup_t` keyed by thread id is gone with it; `TlsGetValue` answers
    the same question without a list walk or a thread-id lookup.

  * All three `g_mutex` acquisitions go through `loq_lock()`, which keeps the
    fast-path-then-bounded-spin shape from ada31ca instead of dropping
    straight into the 100-iteration spin.

  * Both serializer vtables use positional initializers. C99 designated
    initializers are not accepted by PlatformToolset v141 in C mode.

  * The `.github/workflows/` changes are dropped. kevoreilly#212 owns CI, and the
    `pr-build-test.yml` copy that was carried here targeted `windows-2019`,
    which no longer exists as a runner image.

The protobuf backend is experimental and lossy: `schema.proto` cannot yet
represent capemon's full call model, and no host-side parser consumes it.
`log_init()` says so over the pipe when it is enabled.

TAG=agy
CONV=b3280e17-abe0-4fed-ad0b-c2e7f65da90f
@doomedraven
doomedraven force-pushed the opt/pluggable-serialization branch from d40f720 to 8222e3d Compare September 16, 2026 12:08
@doomedraven doomedraven changed the title nanopb: Pluggable Logging Strategy Pattern (BSON & Protobuf) nanopb: pluggable logging strategy (BSON default, protobuf opt-in) Sep 16, 2026
@doomedraven
doomedraven marked this pull request as draft September 16, 2026 12:09
@doomedraven

Copy link
Copy Markdown
Contributor Author

Force-pushed. History rewritten, so the earlier review comments no longer line up with the diff — sorry about that. Summary of what moved:

Split out. The thread-local logging context is now #215, on its own. This branch is stacked on it and contains only the serializer vtable and the nanopb backend. #162, which was the first two commits here, is closed.

Dropped the TEB slot. NtTib.ArbitraryUserPointer (fs:[0x14] / gs:[0x28]) is not free — ntdll's loader parks the FullDllName pointer there across its NtMapViewOfSection call, and we hook NtMapViewOfSection. A hook firing during a module load read a PWSTR out of that slot and wrote serializer state over the loader's string. That is a candidate explanation for the detonation failure reported on #162. Now dynamic TLS, in #215.

Dropped TlsThreadCleanup() and the DLL_THREAD_DETACH hook. They never ran: hide_module_from_peb() unlinks us from the loader lists during DLL_PROCESS_ATTACH, so DllMain is never entered again.

Positional initializers for both serializer vtables. .init = ... designated initializers are C99 and capemon.vcxproj targets PlatformToolset v141, which does not accept them in C mode. This would not have built on VS2017 even after the finish/append_finish fix. (Moot if #187 retargets to VS2022, but the project file still says v141 today.)

Dropped the .github/workflows/ changes. #212 owns CI. The pr-build-test.yml copy carried here targeted windows-2019, which is no longer a runner image — that is why every run on it sat in the queue for 24h and got cancelled rather than failing.

Restored the delay-load settings. The previous revision's capemon.vcxproj predated 321a8e0 and would have reverted it.

@rkoumis the logtbl_explained comment you asked for is still there, plus a note that the block is skipped entirely in protobuf mode so the stream never mixes frame types.

Still not compiled — the MSBuild workflow is disabled_manually at the repo level, so nothing in this series has been near a compiler.

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.

3 participants