A Quick Intro
So this one started as a weekend curiosity and ended with a CVE, a VulDB entry, and a vendor who never replied to my emails. It's not a glamorous bug. It's a local denial of service in a Windows DLL. But it's mine, I found it, and I think the story is worth writing down.
The bug is CVE-2026-120226, a CWE-120 buffer overflow in P2PAPI.dll version 1.3.3.32, shipped with the O-KAM / SuperIPCam Windows client made by Hangzhou Xiongmai Technology.
Let me walk you through what the DLL does, what I found, and how I confirmed it.
Where to find it: - VulDB: VDB-429585 - MITRE: CVE-2026-120226 - NVD: CVE-2026-120226 - CVE Details: CVE-2026-120226 - EUVD (ENISA): CVE-2026-120226 - GitHub Advisory: GHSA-G8F3-PHXQ-9GGC
Tooling I Used
I want to be upfront about the toolchain because I think it matters for reproducibility.
Ghidra did the heavy lifting. P2PAPI.dll is a 32-bit native x86 PE, so Ghidra was the right tool for the decompilation. I loaded the DLL with the default x86 processor, let the auto-analysis run, and spent most of my time in the decompiler view tracing the constructors that P2PAPI_Connect reaches.
dnSpy was useful on the client side. The SuperIPCam executable and its surrounding managed components hand off the auth string to the native DLL at a specific call boundary, and dnSpy made it easier to see how the string is built before it reaches P2PAPI_Connect. It's not where the bug lives, but it's where I confirmed that the argument shape I was sending actually matched what the client sends in practice.
The two tools together gave me the full picture: dnSpy on the managed side to understand the call, Ghidra on the native side to find the unsafe copy.
First, What Is P2PAPI.dll?
If you've never touched Xiongmai gear, here's the short version. Xiongmai makes OEM surveillance hardware (IP cameras, DVRs, NVRs), and those devices get rebranded and sold under dozens of names. They sit behind NAT, on dynamic IPs, with no port forwarding configured. So you can't just reach them directly from a Windows app sitting somewhere else on the internet.
P2PAPI.dll is the piece of software on the Windows side that fixes that. It's a P2P connection manager. It lets the desktop client (O-KAM / SuperIPCam in this case) reach a camera through Xiongmai's cloud relay, without the user having to touch a router.
What it actually does:
- Keeps track of up to 512 concurrent camera sessions, each with its own state, callbacks, and background threads.
- Reads a prefix from the auth string (
VSTD,VSTF,VP,GCMN, etc.) and uses it to pick which relay server pool to talk to. Different prefixes go tovstarcam2018,vstarcam2019,veepai2023,vstarcam2024, and so on, so older devices land on the right backend. - Once connected, opens four logical channels: commands, audio/video, and control status.
- Hands decoded video, audio, and control data back to the application through callbacks like
P2PAPI_SetAVDataCallBackandP2PAPI_SetMessageCallBack. - Runs a reconnect state machine when the connection drops.
In other words, it's the glue between the Windows client and a camera you can't reach directly. Without it, the app has no way to find your device.
That's also why I care about this bug. Every O-KAM / SuperIPCam install has this DLL loaded. It's not some obscure optional component. It's on every machine that has the client. The attack is local, sure. But the install base is not small.
Peeking Inside
P2PAPI.dll sits on top of two lower layers:
P2PAPI.dll -> session slots, channel objects, threads, callbacks PPCS SDK -> encrypted relay transport (PPCS_ConnectByServer, PPCS_Read/Write) XQP2P SDK -> Xiongmai's own P2P / NAT-traversal protocol
I spent most of my time in the top layer, because that's where the string parsing happens.
When you call P2PAPI_Connect, an internal factory (FUN_10003e30) reads the first 4 bytes of your auth string and decides which of three channel classes to build:
| Class | Size | Constructor | Prefix |
|---|---|---|---|
CPPPP3Channel | 0x16C (364 B) | FUN_10007fd0 | VSTD, VSTF, QHSV, EEEE, ROSS, ISRP, GCMN, ELSA |
CPPPP2Channel | 0x6F4 (1780 B) | FUN_10004ef0 | VP |
CPPPPChannel | 0x6F4 (1780 B) | FUN_1000b500 | everything else |
The constructor parses the auth string into three fields and copies them into fixed-size buffers inside the object.
You can probably guess where this is going.
The Bug
I was looking at FUN_10007fd0 in Ghidra, the VSTD constructor, the smallest of the three, and this is what the decompiler produced:
c _memset(param_1 + 1, 0, 0x40); // 64-byte buffer at +0x04 iVar5 = (int)(param_1 + 1) - (int)param_3; do { cVar1 = *param_3; param_3[iVar5] = cVar1; // write to param_1 + i param_3 = param_3 + 1; } while (cVar1 != '\0'); // NO LENGTH LIMIT
There it is. The classic strcpy-style loop. It copies byte by byte, stops when it hits a null terminator, and never checks whether the input is shorter than the 64-byte destination. Same pattern repeats for the second buffer at +0x44 and the third at +0x84.
The object layout is the part that made me wince:
+0x00 vtable +0x04 64-byte buffer <- field A +0x44 64-byte buffer <- field B +0x84 64-byte buffer <- field C +0xC4 control fields +0x104 CCriticalSection vtable +0x16C end of allocation
Three 64-byte buffers packed 64 bytes apart, in a 364-byte allocation, with control fields and a critical-section vtable immediately after. No padding, no slack, nothing.
Why VSTD Crashes and VP Doesn't
Here's the detail I think is most interesting about this bug.
The VP constructor (FUN_10004ef0) has the exact same unbounded copy pattern. Same mistake, same code shape. But the VP object is 1780 bytes, five times larger. The same overflow sizes that kill the VSTD path just get absorbed by the extra space.
The VSTD object is small and tightly packed. A 512-byte overflow starting at +0x84 ends at +0x284, which is 280 bytes past the end of the allocation. With page heap enabled, the write hits an unmapped guard page and the process dies immediately.
Same bug. Different blast radius. This is why "the code is unsafe" and "the code crashes" are not the same statement.
Proving It
I wrote a small Go PoC that loads the DLL, resolves the exports, creates an instance, and calls P2PAPI_Connect with a payload shaped like this:
"VSTD" + "A"*256 + "&" + "B"*128 + "&" + "C"*512 + "\0"
Two independent inputs, both deterministic:
| Prefix | A | B | C | Exit Code | Status |
|---|---|---|---|---|---|
| VSTD | 256 | 128 | 512 | -1073741819 | STATUS_ACCESS_VIOLATION |
| VSTD | 1024 | 16 | 16 | -1073741819 | STATUS_ACCESS_VIOLATION |
The Go runtime caught the crash and dumped the context:
Exception 0xc0000005 0x1 0x23f2f000 0x6e4380c3 PC=0x6e4380c3 <- inside P2PAPI.dll's code region EDI=0x23f2ecc4 ESI=0x23f2ecc0 <- heap pointers near the corrupted buffers
The faulting address 0x23f2f000 is an unmapped page boundary. That's exactly what you'd expect from a write that's walked off the end of the heap allocation.
I also ran controls: VP 256 128 512, VP 1024 16 16, VSTD 16 16 16, and XY 300 100 400 all exited cleanly. Only the VSTD path crashed. That's the confirmation I needed. It's not just "the DLL dies sometimes", it's "the VSTD constructor has a specific, reproducible overflow."
The full sweep transcript, including markers and stack dumps, is in the public Gist.
The Siblings Have the Same Problem
FUN_1000b500 and FUN_10004ef0, the two larger classes, use sscanf instead of hand-rolled loops, but the pattern is just as unsafe:
c sscanf(param_4, "%*c%*c%[^&]%*c%[^&]%*c%s", +0x444 /*0x80*/, +0x504 /*0x40*/, +0x544 /*0x100*/);
No field widths. Three unbounded conversions in a single call. They just don't crash the sizes I tested, because the object has enough room to absorb the corruption.
Point is, this isn't a one-line mistake in one function. The same unsafe-copy style shows up in all three constructors. The VSTD path is just the one where it's guaranteed to hit a page boundary.
What I'm Claiming and What I'm Not
I want to be really careful here because I've seen too many advisories overstate their findings. Here's where I stand:
Confirmed: - Local denial of service. Any process that loads P2PAPI.dll and calls P2PAPI_Connect with a VSTD-prefixed oversized second argument dies with an access violation. - Deterministic. Two independent inputs, same signature, every time. - CWE-120. Classic buffer copy without a bounds check.
Not established: - Remote reachability. I did not trace the second argument back to a network-controlled source in the client binary. If someone does, this becomes a bigger deal. But until that chain is shown, I'm not claiming it. - Code execution. The overflow reaches an unmapped page before any controllable write. I did not build a heap-grooming chain or a ROP primitive.
One thing worth mentioning but not claiming: the PE header shows Control Flow Guard is disabled (DllCharacteristics = 0x0140, no GUARD_CF bit), and the CCriticalSection vtable sits at +0x104, reachable by a carefully-sized ~100-byte overflow of field A. With CFG off, a virtual call through a corrupted vtable is plausible in principle. I didn't demonstrate it. I'm noting it as an aggravating factor, not a finding.
I'd rather be accurate about a small bug than wrong about a big one.
Fixing It
If you're building on top of this SDK, here's the interim mitigation:
Bound every field before calling P2PAPI_Connect. For the VSTD path, cap each of the three fields at 63 bytes.
For the vendor, the real fix is straightforward:
c strncpy_s((char *)(param_1 + 1), 0x40, param_3, _TRUNCATE); strncpy_s((char *)(param_1 + 0x11), 0x40, param_4, _TRUNCATE); strncpy_s((char *)(param_1 + 0x21), 0x40, param_5, _TRUNCATE);
And bound the sscanf conversions to match destination sizes:
c sscanf(input, "%*c%*c%127[^&]%*c%63[^&]%*c%255s", buf_a, buf_b, buf_c);
While they're in there: turn on /guard:cf and confirm /GS cookies are active in release builds. Neither is a fix, but both raise the cost of exploitation if this class of bug shows up again.
Timeline
| Date | What Happened |
|---|---|
| 2026-10-04 | Found it via static analysis in Ghidra |
| 2026-10-05 | Confirmed the crash dynamically, captured evidence |
| 2026-10-05 | Contacted the vendor (no response) |
| 2026-10-31 | Published the advisory and PoC, VulDB entry created |
| 2026-11-01 | Entry last updated |
References
| Source | Link |
|---|---|
| VulDB entry | VDB-429585 |
| MITRE CVE record | CVE-2026-120226 |
| NVD | CVE-2026-120226 |
| CVE Details | CVE-2026-120226 |
| CVE List v5 API | https://cveawg.mitre.org/api/cve/CVE-2026-120226 |
| GCVE identifier | GCVE-0-2026-120226 / GCVE-100-429585 |
| PoC + evidence | GitHub Gist |
| Ghidra | NationalSecurityAgency/ghidra |
| dnSpy | dnSpyEx/dnSpy |
| CWE | CWE-120 |
| CAPEC | CAPEC-10 |
Wrapping Up
This is a low-severity local DoS. I know that. It's not going to get me on the news and it's not going to earn a big bounty. But I found it, I confirmed it, I documented it properly, and I published it responsibly after the vendor ignored me.
What I think makes it worth writing about is the shape of the bug. Three constructors, three unbounded copies, one of them small enough to walk off a page. That's the kind of thing you only catch by actually reading the decompilation. Static scanners will flag strcpy and move on, but they won't tell you which object layout makes it fatal. Ghidra and dnSpy together gave me the visibility to see both halves of that picture.
And honestly? P2PAPI.dll ships with every O-KAM / SuperIPCam install. It's on a lot of machines. The vendor didn't respond, so the advisory, the PoC, and the workaround are the deliverable.
If you're building on this SDK: bound your strings before you pass them in. If you're the vendor: it's a strncpy_s away.
CVE-2026-120226 · VulDB VDB-429585 · MITRE · NVD · CVE Details