Defender rejects your file. Exit code 2, no further detail. You don’t get an offset, a rule name, or a hint about what triggered it. You get “this file is bad.”

The workaround is obvious in retrospect: if you can split the file and scan each half, the half that still triggers contains the signature. Split that half again. Keep going until you’ve isolated the triggering region to a few hundred bytes. Then read those bytes and figure out what they are.

This is DefenderCheck’s core idea. This post reimplements it in C and walks through what the isolated bytes actually tell you.

How MpCmdRun works

Windows Defender exposes a command-line scanner at C:\Program Files\Windows Defender\MpCmdRun.exe. That is the legacy location. Current builds ship the versioned binary under C:\ProgramData\Microsoft\Windows Defender\Platform\<version>\MpCmdRun.exe. The relevant invocation:

text
MpCmdRun.exe -Scan -ScanType 3 -DisableRemediation -File "C:\path\to\file"

Exit code 0 means clean. Exit code 2: threat found, user action required, or scan error. A scan infrastructure failure returns 2 as well, falsely appearing as a detection and driving bisection the wrong way. There’s no machine-readable output telling you what triggered it, which is the entire problem this technique solves.

The scanner operates on a file path. So the bisection loop writes each half to a temp file, invokes MpCmdRun, and checks the exit code. No API, no COM interface, no special privilege beyond being able to write temp files and spawn a process.

A clean implementation

This implements the bisection loop cleanly. It maps the target file, then recursively narrows the range until the triggering region is at most 512 bytes.

c
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Legacy path. Current builds: C:\ProgramData\Microsoft\Windows Defender\Platform\<version>\MpCmdRun.exe */
#define MPCMDRUN "C:\\Program Files\\Windows Defender\\MpCmdRun.exe"
#define MIN_REGION 512

/* Write 'len' bytes of 'data' to a temp file. Returns 1 on success. */
static int write_temp(const char *path, const BYTE *data, size_t len) {
    HANDLE h = CreateFileA(path, GENERIC_WRITE, 0, NULL,
                           CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    if (h == INVALID_HANDLE_VALUE) return 0;
    DWORD written;
    BOOL ok = WriteFile(h, data, (DWORD)len, &written, NULL);
    CloseHandle(h);
    return ok && written == (DWORD)len;
}

/*
 * Invoke MpCmdRun on a file.
 * Returns 1 if Defender flags it, 0 if clean, -1 on error.
 */
static int scan_file(const char *path) {
    char cmd[512];
    /* Single outer quote set around MpCmdRun path, separate quote for file. */
    snprintf(cmd, sizeof(cmd),
             "\"%s\" -Scan -ScanType 3 -DisableRemediation -File \"%s\"",
             MPCMDRUN, path);

    STARTUPINFOA si = { sizeof(si) };
    PROCESS_INFORMATION pi = { 0 };

    /* Suppress MpCmdRun console output by redirecting to NUL. */
    HANDLE nul = CreateFileA("nul", GENERIC_WRITE, FILE_SHARE_WRITE,
                             NULL, OPEN_EXISTING, 0, NULL);
    si.dwFlags = STARTF_USESTDHANDLES;
    si.hStdInput  = GetStdHandle(STD_INPUT_HANDLE);
    si.hStdOutput = nul;
    si.hStdError  = nul;

    if (!CreateProcessA(NULL, cmd, NULL, NULL, TRUE,
                        CREATE_NO_WINDOW, NULL, NULL, &si, &pi)) {
        if (nul != INVALID_HANDLE_VALUE) CloseHandle(nul);
        return -1;
    }

    if (nul != INVALID_HANDLE_VALUE) CloseHandle(nul);

    WaitForSingleObject(pi.hProcess, 30000);

    DWORD code = 0;
    GetExitCodeProcess(pi.hProcess, &code);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);

    /* MpCmdRun exit codes: 0 = clean, 2 = threat found / user action required / scan error */
    return (code == 2) ? 1 : 0;
}

/*
 * Binary-search [offset, offset+len) for the triggering region.
 * Writes progress to stdout. When len <= MIN_REGION, dumps the bytes.
 */
static void bisect(const BYTE *data, size_t file_size,
                   size_t offset, size_t len,
                   const char *tmp) {
    if (len <= MIN_REGION) {
        printf("[+] Triggering region: offset 0x%zx, %zu bytes\n",
               offset, len);
        printf("    Hex dump:\n    ");
        for (size_t i = 0; i < len; i++) {
            printf("%02x ", data[offset + i]);
            if ((i + 1) % 16 == 0) printf("\n    ");
        }
        printf("\n");
        return;
    }

    size_t half = len / 2;

    /* Scan first half */
    if (!write_temp(tmp, data + offset, half)) {
        fprintf(stderr, "[-] write_temp failed\n");
        return;
    }
    int r = scan_file(tmp);
    if (r == 1) {
        printf("[*] Trigger in first half  [0x%zx, 0x%zx)\n",
               offset, offset + half);
        bisect(data, file_size, offset, half, tmp);
        return;
    }

    /* Scan second half */
    if (!write_temp(tmp, data + offset + half, len - half)) {
        fprintf(stderr, "[-] write_temp failed\n");
        return;
    }
    r = scan_file(tmp);
    if (r == 1) {
        printf("[*] Trigger in second half [0x%zx, 0x%zx)\n",
               offset + half, offset + len);
        bisect(data, file_size, offset + half, len - half, tmp);
        return;
    }

    /*
     * Neither half triggers alone. The signature spans the split point,
     * or requires context from both halves (e.g. import table + code section).
     * Back up one level with a larger minimum region.
     */
    printf("[!] Signature spans split at 0x%zx -- widening window\n",
           offset + half);
    printf("[+] Triggering region: offset 0x%zx, %zu bytes\n", offset, len);
}

int main(int argc, char *argv[]) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <file>\n", argv[0]);
        return 1;
    }

    HANDLE fh = CreateFileA(argv[1], GENERIC_READ, FILE_SHARE_READ,
                            NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    if (fh == INVALID_HANDLE_VALUE) {
        fprintf(stderr, "[-] Cannot open %s (error %lu)\n",
                argv[1], GetLastError());
        return 1;
    }

    LARGE_INTEGER sz;
    GetFileSizeEx(fh, &sz);
    size_t file_size = (size_t)sz.QuadPart;

    HANDLE fm = CreateFileMappingA(fh, NULL, PAGE_READONLY, 0, 0, NULL);
    CloseHandle(fh);
    if (!fm) {
        fprintf(stderr, "[-] CreateFileMapping failed\n");
        return 1;
    }

    BYTE *data = (BYTE *)MapViewOfFile(fm, FILE_MAP_READ, 0, 0, 0);
    CloseHandle(fm);
    if (!data) {
        fprintf(stderr, "[-] MapViewOfFile failed\n");
        return 1;
    }

    /* First confirm the full file triggers. */
    char tmp[MAX_PATH];
    GetTempPathA(MAX_PATH, tmp);
    strncat_s(tmp, MAX_PATH, "avscan_tmp.bin", _TRUNCATE);

    if (!write_temp(tmp, data, file_size)) {
        fprintf(stderr, "[-] Could not write temp file\n");
        UnmapViewOfFile(data);
        return 1;
    }

    printf("[*] Scanning full file (%zu bytes)...\n", file_size);
    int r = scan_file(tmp);
    if (r != 1) {
        printf("[-] Full file is clean. Nothing to bisect.\n");
        DeleteFileA(tmp);
        UnmapViewOfFile(data);
        return 0;
    }

    printf("[+] Confirmed: full file triggers Defender\n");
    printf("[*] Starting bisection...\n\n");

    bisect(data, file_size, 0, file_size, tmp);

    DeleteFileA(tmp);
    UnmapViewOfFile(data);
    return 0;
}

The recursion terminates at MIN_REGION bytes. Adjust that constant depending on how precise you want to get. 512 bytes is usually enough to identify what the signature is targeting.

Note the span case. If neither half triggers in isolation, the signature requires bytes from both halves to match. This happens with some import table signatures: the scanner needs the IMAGE_IMPORT_DESCRIPTOR chain intact to recognize the API sequence. When that happens, widen the minimum region and accept a larger search window.

Running against EICAR

EICAR is a standard test string that every AV vendor treats as a detection, without the string itself being malware. The canonical form is:

text
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*

Write that to a file and run the bisector against it. With only 68 bytes the bisection terminates on the first iteration: the file is too small to split further than MIN_REGION, so you get the full content back immediately. The point of using EICAR isn’t the bisection itself, it’s confirming the scan infrastructure works before you run it on something larger.

Expected output against a small EICAR file (68 bytes):

text
[*] Scanning full file (68 bytes)...
[+] Confirmed: full file triggers Defender
[*] Starting bisection...

[+] Triggering region: offset 0x0, 68 bytes
    Hex dump:
    58 35 4f 21 50 25 40 41 50 5b 34 5c 50 5a 58 35
    34 28 50 5e 29 37 43 43 29 37 7d 24 45 49 43 41
    52 2d 53 54 41 4e 44 41 52 44 2d 41 4e 54 49 56
    49 52 55 53 2d 54 45 53 54 2d 46 49 4c 45 21 24
    48 2b 48 2a

That hex is literally the EICAR string. A string constant signature.

What the bytes tell you

The offset and content of the triggering region put you in one of three situations.

String constant. The bytes decode to a recognizable string: a known shellcode stage name, a C2 URL, a mutex name, an AMSI bypass pattern, an API name sequence in the import table. These signatures are cheap to write and cheap to evade. Modify the string, XOR-encode it, split it across two allocations. The signature breaks.

The EICAR case above is this. The entire detection is the literal string. Change one byte and Defender stops caring.

A real example of this type: a loader that imports VirtualAlloc, WriteProcessMemory, and CreateRemoteThread in that order. Some AV engines detect the import sequence, not the individual imports. The triggering bytes are in the import descriptor table. The import directory RVA is stored in IMAGE_OPTIONAL_HEADER.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT] (DataDirectory[1]); the actual IMAGE_IMPORT_DESCRIPTOR array lives at that RVA in the .idata section, not at any predictable fixed offset from e_lfanew. If the flagged bytes land there, it’s a structural string match on the import table.

PE structure field. The offset lands in the PE header. Check which field:

  • IMAGE_OPTIONAL_HEADER.Subsystem = 1 (IMAGE_SUBSYSTEM_NATIVE): native executable, no subsystem dependency. Common in kernel-mode implants compiled as EXEs for manual mapping.
  • Section name .shellcode or .txt or a blank section name with IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_WRITE: non-standard section attributes are a reliable indicator.
  • IMAGE_FILE_HEADER.TimeDateStamp matching a known packer or builder tool. Some packer families reuse a fixed timestamp and AV vendors fingerprint it.
  • IMAGE_OPTIONAL_HEADER.CheckSum of zero on a signed binary, or a specific value from a known toolchain.

These are structural signatures. They don’t care what the code does. A PE with subsystem 1 and unusual section flags triggers regardless of content. Fixing them means changing the PE header to look more conventional.

Code sequence. The offset is inside .text or .data. The bytes are opcodes or data that match a specific sequence. This is the most information-rich case.

A code sequence signature tells you one of three things:

  1. The signature is targeting a specific shellcode stage: a reflective loader stub, a syscall dispatcher, a position-independent function prologue from a known framework. The byte sequence is essentially a fingerprint of the toolchain. Cobalt Strike’s default Beacon has well-documented sequences. Sliver has different ones. Havoc has different ones again. If the bytes match a known stub, you’re not going to evade by patching one byte: the detection engine may be looking for a fuzzy match.

  2. The signature is targeting a specific packer or crypter output. Packed executables have characteristic entropy and often characteristic decompression stubs. The bisection lands in the stub. The fix is a different packer.

  3. The signature is a generic heuristic on code structure: a function that allocates executable memory, writes to it, then creates a thread, expressed as a byte-level pattern over the compiled code. These are narrower than they sound. Recompile with different optimization flags, change the calling convention, or move the function to a different translation unit and the pattern breaks.

In all three cases, knowing what the bytes are is step one. The bisector gives you the offset. A hex editor and a disassembler give you the rest.

The bisection loop as a diagram

Bisection loopfull file → scantriggers?nocleanyesfirst half → scansecond half → scantriggers?triggers?yesyesboth clean?span caserecurse leftrecurse rightlen ≤ MIN_REGION?len ≤ MIN_REGION?→ dump→ dump

Limitations

Span signatures. A signature that matches bytes spanning the split point will not be found in either half. The code detects this and reports it, but doesn’t automatically widen and retry. If you hit this case repeatedly, lower MIN_REGION and accept that the final window is larger.

Repeated scans. If you’re scanning the same byte content repeatedly, be aware that each bisection half is genuinely different content, so there’s no stale-result concern during the bisection itself. If you’re testing the same modification in a loop outside of bisection, vary the content slightly to avoid any engine-internal short-circuit on identical bytes.

Cloud signatures. Some detections require the MAPS (cloud) lookup. The scanner can fail to trigger if MAPS is unavailable or if the file is below the cloud submission threshold. In practice, most file-based signatures work offline. If your file triggers with cloud scanning enabled but not in offline mode, you’re dealing with a reputation or cloud-only signature, and the bisector won’t help you find a specific byte region.

Dynamic analysis. None of this applies to behavioral signatures. If Defender is triggering on what your code does at runtime rather than what it looks like statically, the bisector will report the file as clean because it never executes it. You’d need a different approach: instrument the execution and trace which API calls or memory operations trigger the ETW provider that feeds the behavioral engine.

The bisector is a static analysis tool. It isolates static signatures. Anything behavioral is out of scope.

Where to go from here

Once you have the offset and the bytes, the next step depends on what you found. For string constants, the process injection post covers why import table visibility matters and how the runtime behavior generates additional telemetry beyond the static signature. For PE structure fields, understanding what each header field signals to a scanner is worth a separate read of the PE specification. For code sequences, the inline hook post covers the byte-level structure of x64 code in enough detail to make sense of what a disassembler shows you at the flagged offset.

The bisector tells you where. Reading the bytes tells you what. Those are two different pieces of information and both matter.