A Quick Intro

About a month ago, I found a local DoS in P2PAPI.dll from Xiongmai's P2P SDK; CVE-2026-120226 was assigned the day before this post. That one was clean and contained: one constructor, one unbounded copy, one crash, done. This post is the follow-up — two more CVEs from the same O-KAM / SuperIPCam 1.3.3.32 install base, found the same weekend, published through VulDB.

They're not the same bug. One is a stack-based buffer overflow in DevDll_925.dll, reachable from the P2P SetDeviceInfo path. The other is a use-after-free race in SuperIPCam.exe's session teardown. Different classes, different components, different quality of evidence. But they share the same root-cause habit I keep finding in this codebase: hand-rolled parsers that trust input length and trust object lifetime.

Here's the pair:

  • CVE-2026-120336 — CWE-121 stack-based buffer overflow in DevDll_925.dll!FUN_10003EA0 (VDB-429661)
  • CVE-2026-120337 — CWE-416 + CWE-362 use-after-free race in SuperIPCam.exe!FUN_005a2d44 (VDB-429662)

Both vendor contacts went unanswered. Both are published with PoCs. One is materially stronger than the other, and I want to be upfront about which and why.

Where to find them: - VulDB: VDB-429661 · VDB-429662 - MITRE: CVE-2026-120336 · CVE-2026-120337 - NVD: CVE-2026-120336 · CVE-2026-120337 - GitHub Gists: overflow PoC · race PoC

Tooling I Used

Same toolchain as last time, because it works for this codebase and I'm not going to pretend I picked something fancier.

Ghidra did the heavy lifting again. Both bugs live in 32-bit native x86 PE binaries — DevDll_925.dll and SuperIPCam.exe — so it was the right tool for the decompilation. The overflow fell out of reading FUN_10003EA0 line by line. The race pattern fell out of putting three sibling functions in the session manager side by side and noticing that one of them didn't follow the locking convention the other two did.

i686-w64-mingw32-gcc for the 32-bit overflow PoC. The DLL is 32-bit, MSVC was overkill for a throwaway harness, and cross-compiling with MinGW meant I could reproduce from a normal shell.

Go for the race model. Go's -race detector is the fastest way to demonstrate that a given interleaving is unsynchronized without arguing about scheduling — it either fires or it doesn't, and if it fires four times on your modeled pattern, you have a data race by definition.

Component 1: DevDll_925.dll

DevDll_925.dll is the device abstraction layer in the O-KAM client. Where P2PAPI.dll handles transport, this one handles the device — serial number, IP, auth token, vendor prefix, and the callbacks the rest of the app uses to talk to one specific camera. Multi-camera views mean multiple instances of this object, one per device.

The class layout uses MSVC's SSO std::string, so each of the four fields sits at a known offset:

+0x114 std::string serial (_Bx[16] | _Mysize | _Myres) +0x14c std::string ip +0x16c std::string auth +0x188 std::string prefix

Those fields are set by two setters — FUN_10004b50 (serial, IP, port) and FUN_10004af0 (auth, prefix). Both are reachable from the P2P SetDeviceInfo message handler, which is the same path a rogue camera or MITM uses to advertise itself. Neither setter bounds the input. That's the setup.

The Stack Overflow

FUN_10003EA0 materializes the device object. It reads those four strings and copies them into fixed-size stack buffers:

```c void __thiscall FUN_10003ea0(void this, int param_1) { CHAR local_59c[256]; // auth CHAR local_49c[64]; // prefix CHAR local_45c[64]; // IP CHAR local_418[1028]; // serial / ... */

pCVar1 = (LPCSTR)((int)this + 0x114);
if (0xf < *(uint *)((int)this + 0x128))
    pCVar1 = *(LPCSTR *)pCVar1;
lstrcpyA(local_418, pCVar1);         // 1028-byte dest, no bound

pCVar1 = (LPCSTR)((int)this + 0x14c);
if (0xf < *(uint *)((int)this + 0x160))
    pCVar1 = *(LPCSTR *)pCVar1;
lstrcpyA(local_45c, pCVar1);         // 64-byte dest, no bound

pCVar1 = (LPCSTR)((int)this + 0x16c);
if (0xf < *(uint *)((int)this + 0x180))
    pCVar1 = *(LPCSTR *)pCVar1;
lstrcpyA(local_59c, pCVar1);         // 256-byte dest, no bound

pCVar1 = (LPCSTR)((int)this + 0x188);
if (0xf < *(uint *)((int)this + 0x19c))
    pCVar1 = *(LPCSTR *)pCVar1;
lstrcpyA(local_49c, pCVar1);         // 64-byte dest, no bound
/* ... */
__security_check_cookie(local_14 ^ (uint)&stack0xfffffffc);

} ```

Four lstrcpyA calls, four fixed-size destinations, zero length checks. The compiler inserted a /GS cookie precisely because this function is stack-unsafe — but the cookie only detects the write, it doesn't prevent it. By the time __security_check_cookie fires, the attacker's bytes are already past the canary, past the saved EBP, and past the return address.

The stack layout, from each buffer to the canary:

BufferSizeBytes to canaryBytes to local_10
local_45c (IP)6410961100
local_49c (prefix)6411601164
local_59c (auth)25614161420
local_418 (serial)102810281032

The cleanest smash, if you want a single unambiguous crash signature, is exactly one byte short of local_10 on each buffer — clobber the cookie and nothing else, so __security_check_cookie fires fastfail with STATUS_STACK_BUFFER_OVERRUN and no incidental AV noise. Those lengths are: IP 1099, prefix 1163, auth 1419, serial 1031.

Proving the Overflow

Small 32-bit C harness, CreateProcess-based so the child's exit code survives and I can read it cleanly. For each target field: load the DLL, allocate a fake this object at the right offsets, set the chosen field to a payload of 'A' bytes, call FUN_10003EA0, read the exit code.

test.exe DevDll_925.dll 0 1031 # serial -> 0xC0000409 test.exe DevDll_925.dll 1 1099 # IP -> 0xC0000409 test.exe DevDll_925.dll 2 1419 # auth -> 0xC0000409 test.exe DevDll_925.dll 3 1163 # prefix -> 0xC0000409

Four targets, four clean 0xC0000409 exits, four confirmations that the canary was smashed. 0xC0000409 here is __fastfail from __security_check_cookie — uncatchable with SEH, terminated by the kernel.

One negative result worth mentioning because it gets misread as "the bug doesn't work": at 1500 bytes on the IP buffer, the exit code is 0xC0000005 instead of 0xC0000409. That's expected. At 1500 bytes you've already overwritten local_10 (the saved ExceptionList head), the EH state, the saved EBP, and the return address. When the function eventually tries to unwind or return, you fault on an invalid pointer before you ever reach __security_check_cookie. It's still the overflow — it's just a different crash site because the payload reached the return path. Same bug, later crash.

Component 2: SuperIPCam.exe Session Teardown

Separate bug, separate binary, different class. Where DevDll_925.dll handles device state, SuperIPCam.exe handles the session — the live P2P connection, sample callbacks, and teardown.

The session object has a critical section pointer at +0x44, a state field at +0x49, and a vtable at +0x00. The convention in this codebase, when it's followed, is to hold the critical section across any read or write to session state. That convention is documented by the background thread at FUN_005a2a44, which does it correctly:

c while (DAT_0073f1a0 != 0) { FUN_0043c874(DAT_0073f1a0); // EnterCS bVar1 = DAT_0073f1a4 == 0; // read state under lock FUN_0043c87c(DAT_0073f1a0); // LeaveCS if (bVar1) { FUN_004117a8(&DAT_0073f1a0); } else { Sleep(5000); } }

The teardown path at FUN_005a2d44 does not follow the convention:

c piVar2 = (int *)FUN_004044cc(); // get session (**(code **)(*piVar2 + 0x34))(); // stop — no lock FUN_0042c7f8(piVar2, ...); // teardown — no lock (**(code **)(*piVar2 + 0x18))(); // dtor — frees CS FUN_0043c874(piVar2[0x11], ...); // EnterCS(freed CS) — UAF FUN_0043c87c(piVar2[0x11], ...); // LeaveCS(freed CS) — UAF

The destructor at vtable +0x18 frees the critical section at session+0x44. Every subsequent lock operation — EnterCriticalSection, LeaveCriticalSection — is operating on freed memory. Meanwhile the sample callback at FUN_005a2e90 is also locking that same critical section, also without coordination with the destroy path:

c void FUN_005a2e90(int *param_1) { FUN_0043c874(param_1[0x11]); // EnterCS cVar2 = FUN_005a2ef8(local_8); if (cVar2 == '\0') { if ((char)local_8[0x12] == '\0') (**(code **)(*local_8 + 0x34))(); *(undefined1 *)((int)local_8 + 0x49) = 1; } FUN_0043c87c(local_8[0x11]); // LeaveCS }

If teardown runs while a sample callback is in-flight, you have a classic use-after-free: the callback holds a lock on a critical section that teardown is simultaneously deleting. Under the right interleaving, allocator metadata gets corrupted, and the process dies.

Proving the Race

Two independent confirmations of the pattern:

  • An isolated C model (zd8_real.c) reproduces the crash in 100% of child processes with STATUS_HEAP_CORRUPTION (0xC0000374). The pattern is confirmed, in isolation.
  • The Go race detector (zd8_race.go) fires four WARNING: DATA RACE on the modeled interleaving. The pattern is confirmed again, in a model.

The live crash in SuperIPCam.exe — memory collapse from 42 MB to 20 KB followed by Access violation at address 006D2CE2, read of 0x00000000 — is the headline result. The full 5000-round targeted test transcript in the Gist shows the interleaving under load, and the crash dialog is captured as zd8_crash_dialog.png. The null read at 0x006D2CE2 (RVA 0x2D2CE2, inside SuperIPCam.exe) is consistent with a freed object whose vtable or field table was zeroed during teardown.

What I'm Claiming

CVE-2026-120336 (stack overflow):

  • Four independent stack-buffer overflows in DevDll_925.dll!FUN_10003EA0, all reachable through the SetDeviceInfo path via FUN_10004b50 / FUN_10004af0.
  • Deterministic canary smash and saved-control-data overwrite with attacker-controlled bytes. 0xC0000409 on all four fields.
  • Full byte-level control of the bytes past the canary and past the saved return address — the primitive is EIP control, contingent on ASLR and DEP work I have not done.

CVE-2026-120337 (UAF race):

  • The unsynchronized teardown pattern in FUN_005a2d44 vs the correctly locked background thread in FUN_005a2a44.
  • Race confirmed in an isolated C model — 100% 0xC0000374.
  • Race confirmed by Go's race detector on the modeled pattern.
  • Live crash in SuperIPCam.exe under targeted message flood, at RVA 0x2D2CE2, consistent with a stale pointer into a freed session object.

What VulDB Did With Them

Both got 7.3 base, 6.6 temp: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L/E:P/RL:X/RC:R. I submitted both closer to 9.0 on the overflow and 7.5 on the race. VulDB set every impact sub-score to Low and applied E:P (public PoC). That's a defensible downgrade given that neither has a demonstrated consequence past process death, and the overflow only becomes RCE if someone builds the ASLR/DEP chain I didn't.

The overflow is materially the stronger of the two; the race sits behind a harder-to-reproduce timing window, which is reflected in the same published score. I'm noting it publicly because the grade disagreement is the honest part of the story. "9.0 in the submission, 7.3 published" is more interesting than "vendor accepted my rating."

Fixing It

Stack overflow (DevDll_925.dll):

c StringCchCopyA(local_418, _countof(local_418), pCVar1); StringCchCopyA(local_45c, _countof(local_45c), pCVar1); StringCchCopyA(local_59c, _countof(local_59c), pCVar1); StringCchCopyA(local_49c, _countof(local_49c), pCVar1);

Or strncpy_s / lstrcpynA with the correct _countof. Better still, cap at the setter layer (FUN_10004b50 / FUN_10004af0) and reject strlen > MAX_FIELD_LEN before storing. Publish the protocol-level limits for SetDeviceInfo.

UAF race (SuperIPCam.exe):

c EnterCriticalSection(s->cs); (*s->vtable->stop)(s); teardown(s); LeaveCriticalSection(s->cs); DeleteCriticalSection(s->cs);

Or refcount the session so it stays alive until all readers exit. The fix is a lock-scope fix, not a rewrite.

Across both:

  • Turn on /guard:cf and confirm /GS is active in release builds. Neither fixes the bug; both raise the cost of exploitation.
  • Audit for the same two patterns elsewhere in this DLL set — "unbounded copy into a fixed-size field" and "destructive op outside the lock." They were not isolated.

Timeline

DateWhat Happened
2026-10-06Found both patterns via static analysis in Ghidra
2026-10-06Stack overflow confirmed, 0xC0000409 on all four fields
2026-10-06Race confirmed in isolated C model, Go race detector
2026-10-06Contacted the vendor (no response)
2026-11-01CVE-2026-120336 published as VDB-429661
2026-11-01CVE-2026-120337 published as VDB-429662
2026-11-01Entries last updated

References

SourceLink
VulDB (overflow)VDB-429661
VulDB (race)VDB-429662
MITRE CVE record (overflow)CVE-2026-120336
MITRE CVE record (race)CVE-2026-120337
NVD (overflow)CVE-2026-120336
NVD (race)CVE-2026-120337
PoC + evidence (overflow)GitHub Gist
PoC + evidence (race)GitHub Gist
GhidraNationalSecurityAgency/ghidra
dnSpydnSpyEx/dnSpy
CWE-121Stack-based Buffer Overflow
CWE-416Use After Free
CWE-362Race Condition
CAPEC-10CAPEC-10

Wrapping Up

Two CVEs, both from the same install base, both caught by reading decompiled code instead of trusting a scanner. The overflow is the cleaner find: four buffers, deterministic crash, clean PoC, and a reachability chain that runs from the P2P SetDeviceInfo message straight into the vulnerable lstrcpyA calls. The race is the more interesting find: a correct locking pattern three functions away from an incorrect one, in the same binary, doing the same class of work.

The honest part is that neither is RCE. The overflow gives you the primitive; the race gives you corruption. Both stop at DoS in what I've actually demonstrated, and VulDB's 7.3 reflects that. If someone wants to take either further — full heap grooming on the overflow, or a stable exploit primitive on the race — the PoCs and the decompilation are public.

What I think makes both worth writing about is the shape, not the severity. Static scanners will flag lstrcpyA and move on, but they won't tell you which object layout makes it fatal, and they won't tell you which sibling function forgot the lock. Ghidra and dnSpy together gave me the visibility to see both halves of both pictures. That's the skill worth practicing.

DevDll_925.dll ships with every O-KAM / SuperIPCam install. SuperIPCam.exe is the client itself. The install base is not small, and the vendor didn't respond, so the advisories, the PoCs, and the workarounds are the deliverable.

If you're building on this SDK: bound your strings, and hold the lock across teardown. Neither is exotic. Both are the kind of thing that has to be enforced at code review, because the compiler will happily hand you a /GS cookie and let you walk right past it.

CVE-2026-120336 · VDB-429661 · MITRE · NVD
CVE-2026-120337 · VDB-429662 · MITRE · NVD