On a hardening checklist, the ASLR item is almost always a single line: kernel.randomize_va_space = 2. All seven of my servers say 2; the setting does not appear in /etc/sysctl.conf or /etc/sysctl.d/ on any of them, so nobody wrote it by hand — it is the distribution default. This morning I asked one more question out of curiosity — "okay, but how many bits?" — and behind that single line I found four separate numbers. The four did not come out equal to each other; they did not come out independent either.
The short answer: in the same process, the code base is randomized over 32 bits while the stack gets 22, and the heap moves only 18 bits relative to the code. There is one more region that behaves differently on the two kernels I measured. And on the ARM side, a linker default quietly takes back 4 of the 18 bits the kernel generously hands out. All measurements were made today: two of the seven servers (Ubuntu 24.04 / kernel 6.8 and Ubuntu 26.04 / kernel 7.0), plus Docker Desktop's own arm64 virtual machine.
Seven servers, one answer, one unreadable file
Inventory first. I tried to read two values on all seven machines in the fleet:
for h in vps1 vps2 vps3 vps4 vps5 vps6 vps7; do
ssh $h 'printf "%s rva=%s rnd=%s\n" "$(uname -r)" \
"$(cat /proc/sys/kernel/randomize_va_space)" \
"$(cat /proc/sys/vm/mmap_rnd_bits 2>/dev/null || echo -)"'
done
The result, tidied up:
| server | kernel | randomize_va_space |
mmap_rnd_bits |
|---|---|---|---|
| vps1, vps2 | 6.14.0-37-generic | 2 | unreadable |
| vps3 | 6.8.0-142-generic | 2 | 32 |
| vps4 | 6.8.0-142-generic | 2 | unreadable |
| vps5, vps6, vps7 | 7.0.0-34-generic | 2 | unreadable |
On six machines mmap_rnd_bits behaved as if it did not exist. The file is right there; the problem was me. I connect to VPS3 as root and to the others as a normal user:
$ stat -c '%a %n' /proc/sys/kernel/randomize_va_space /proc/sys/vm/mmap_rnd_bits
644 /proc/sys/kernel/randomize_va_space
600 /proc/sys/vm/mmap_rnd_bits
Two sibling knobs, two different permissions. Anyone can read the level switch; only root can read the bit count. When I asked again with sudo, all seven machines gave the same answer: mmap_rnd_bits = 32, mmap_rnd_compat_bits = 16. So in this fleet ASLR is not only enabled, it sits at the highest value the architecture allows. I did not do that; Ubuntu did. /boot/config-6.8.0-142-generic contains CONFIG_ARCH_MMAP_RND_BITS=32 and CONFIG_ARCH_MMAP_RND_COMPAT_BITS=16, both equal to the ..._MAX values. That is not upstream's default: arch/Kconfig ties this option to ARCH_MMAP_RND_BITS_DEFAULT first and falls back to ARCH_MMAP_RND_BITS_MIN; since neither x86 nor arm64 defines a ..._DEFAULT, the floor applies on both, and on x86 the MIN is 28. In other words, on a vanilla kernel you run with 28 bits and on Ubuntu with 32. Four bits is a factor of sixteen.
That matters, because every measurement below rests on the assumption of "32", and when I repeated the same experiment on arm64 with 18 bits the shape of the table did not change — only the numbers shifted.
The rig: run the same program a thousand times
The most honest way to estimate a bit count is to count. I wrote a twenty-line program that prints five addresses:
/* probe.c — base address of five regions, once per run */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/auxv.h>
int main(void) {
void *m = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
void *brk0 = sbrk(0);
int onstack;
unsigned long vdso = getauxval(AT_SYSINFO_EHDR);
printf("%p %p %p %p %lx\n", (void *)main, m, brk0, (void *)&onstack, vdso);
return 0;
}
Compiling and running it is two lines:
gcc -O1 -o probe probe.c # PIE (the default); -no-pie and -static-pie were tested too
for i in $(seq 1000); do ./probe; done > out.txt
Then I summarised the output two ways. The first is counting "which bits changed" — easy but misleading, because when you add a random value to a fixed base, the carries move the upper bits too, so 32 bits of entropy look like 34 bits of movement. The second, and the correct one, is the span of the page number: the difference between the largest and the smallest. Over a thousand samples that lands surprisingly close to a power of two.
There is a third check: the birthday paradox. If you throw 1000 samples into N buckets, the expected number of collisions is roughly 1000·999/2N. By counting collisions you verify the entropy through the back door. In a moment, that arithmetic alone will prove the claim that "the linker stole four bits".
The first table: five rows, two numbers
On VPS3 (Ubuntu 24.04, kernel 6.8.0-142, glibc 2.39, mmap_rnd_bits=32), a thousand runs of the PIE-compiled probe. From here on I note which machine and which kernel each measurement came from; by the end of the article it will be clear why that is mandatory:
| region | distinct values | page span | ≈ bits |
|---|---|---|---|
| code (PIE base) | 1000 | 4,293,283,973 | 32.0 |
| mmap (anonymous) | 1000 | 4,287,187,564 | 32.0 |
| brk (heap) | 1000 | 4,293,268,750 | 32.0 |
| stack | 1000 | 4,187,926 | 22.0 |
| vdso | 1000 | 4,187,683 | 22.0 |
Code, libraries and heap sit exactly where mmap_rnd_bits says, at a full 32 bits. The stack does not care about that knob at all: 22 bits. The source explains why: the stack's randomness comes from somewhere else entirely, a fixed architecture mask. From arch/x86/include/asm/elf.h: __STACK_RND_MASK(is32bit) ((is32bit) ? 0x7ff : 0x3fffff), with a comment right next to it saying "16GB for 64bit". 0x3fffff pages times 4 KiB is 16 GiB; the exponent is 22.
There is one more detail in the stack. The lowest changing bit is 4, not 12, so the base address is not even page aligned. The culprit is four lines in arch/x86/kernel/process.c: sp -= get_random_u32_below(8192) followed by sp & ~0xf. An 8 KiB jitter in 16-byte steps on top of the page: 512 possibilities, 9 bits. Adding them up sounds nice (22 + 9), but only one of those 9 bits crosses a page boundary — 8 KiB is two pages — and the rest stays inside the page. For someone attacking at page granularity those eight bits do not exist. The fact that the stack span I measured (4,187,926 pages) sits just below the mask's ceiling of 0x3fffff (4,194,303) is the trace of exactly that one bit. That is why I keep the stack at 22 bits in the table.
The real issue: not absolute entropy, but conditional entropy
In the table the heap looks like 32 bits. That is true and useless. An attacker does not guess the heap address from scratch; they usually hold a leak. The right question is: given the code base, how many bits does it take to find the heap? In the same thousand runs I subtracted the page numbers of pairs of addresses:
(brk - code) : distinct=996 changing bits=18 span=260,801 pages
(vdso - stack): distinct=346 changing bits=9 span=493 pages
(mmap - stack): distinct=1000 changing bits=32 span=4,289,257,952 pages
The heap lives in a 260,801-page window relative to the code, that is, inside 1 GiB. The birthday check points at the same place: with 2^18 buckets and a thousand samples the expected number of collisions is ~1.9; I saw 4, the same order of magnitude. In the non-PIE binary the absolute span of brk came out as 2^18 directly, with exactly 2 collisions in a thousand samples. Two different binaries, one answer.
Then I looked at the source and stumbled. Today's arch/x86/kernel/process.c does say randomize_page(mm->brk, SZ_1G), and 1 GiB / 4 KiB = 262,144 = 2^18, matching the measurement exactly. But VPS3 runs 6.8, and the same file at the v6.8 tag says randomize_page(mm->brk, 0x02000000) — 32 MiB, that is 8192 pages, 13 bits. The 1 GiB window landed upstream in v6.9. Had I written this from the version number, the article would say 13 bits; the machine says 18.
The measurement wins. Ubuntu's 6.8 is not upstream's 6.8, at least in this function — the wider window is already in the distribution kernel. Running the same binary on VPS5, whose kernel is 7.0, also gave 18 bits (261,472 pages), consistent with upstream after 6.9. The conclusion lands in the same place: a bug that leaks the code base drops the heap from 32 bits to 18. But the answer to "which 6.8?" only came from measuring.
A -static-pie binary tilted the table further: the anonymous mmap landed exactly 16 pages below the code base — zero deviation over a thousand runs, precisely 0 bits of conditional entropy. The reason is written in a comment in fs/binfmt_elf.c: a binary without a PT_INTERP is loaded with load_bias = 0, straight into the independently randomized mmap region. So the code itself is already an mmap allocation; the allocation order explains the 16-page gap, but what makes the distance constant is where the binary was loaded.
It is worth writing down what these numbers mean in practice, because the difference between 18 and 32 sounds like "about half". It isn't. 18 bits means 262,144 possibilities; against a service that restarts itself, that is a finite, countable set of attempts. 32 bits is 4.29 billion; the same attack does not finish on the same budget. The 14 bits in between are a factor of 16,384. That is exactly why ASLR fits on one line in hardening tables: without a number, the difference is invisible.
The vdso row is what surprised me most: knowing the stack squeezes the vdso into a 493-page window. Nine bits. The reason sits in the 6.8 source; map_vdso_randomized() derives the address directly from the stack, vdso_addr(current->mm->start_stack, ...), and rounds the window up to a PMD boundary (2 MiB, i.e. 512 pages). The 493 I measured comes out of that 512.
Same binary, two kernels: the vdso moved
There are three different kernels in the fleet; does this vdso–stack coupling hold on all of them? I copied the binary I had compiled on VPS3, untouched, to VPS5 (Ubuntu 26.04, kernel 7.0.0-34, glibc 2.43) and ran it a thousand times. Same binary, same architecture, different kernel:
# vps3, kernel 6.8
(vdso - stack): changing bits=9 span=493 pages
# vps5, kernel 7.0
(vdso - stack): changing bits=32 span=4,289,310,590 pages
Knowing the stack now tells you nothing about the vdso. The reason is in the source: the vdso_addr() function that exists in 6.8 is gone from 6.9 onwards. In today's arch/x86/entry/vdso/vma.c, map_vdso() does not compute the address itself, it leaves it to get_unmapped_area() — so the vdso is now an ordinary mmap.
I was about to write "so 6.9 hardened the vdso", then I ran one more measurement, and I am glad I did. In the same thousand runs I took the vdso relative to the program's own anonymous mmap instead of the stack:
| kernel | vdso, if the stack is known | vdso, if the mmap region is known |
|---|---|---|
| 6.8 (VPS3) | 9 bits (493 pages) | 32 bits |
| 7.0 (VPS5) | 32 bits | 0 bits (a constant 9 pages across a thousand runs) |
6.9 did not harden the vdso; it moved it. Against a stack leak it went from 9 bits to 32, and against a bug that leaks any address in the mmap region it went from 32 bits to zero. The constant 9-page distance is specific to this small program — but the fact that the vdso is now allocated from the same region as the libraries is general; for someone who knows the libc address, the vdso is not a separate unknown. Given that what leaks in the field is usually a libc pointer, the column that matters in practice is the right-hand one.
Most ASLR write-ups on the internet tell you that "the vdso is placed next to the stack". That was true up to 6.9. Without asking the kernel in front of you, you cannot know which sentence is true for which version; two of my servers disagree on this, and both are right.
The four bits the linker stole
I wanted to repeat the experiment on arm64. I have an arm64 Linux handy: Docker Desktop's own virtual machine (kernel 6.10.14-linuxkit). From a privileged container I can freely change that VM's sysctls — and while writing this sentence, let me repeat the warning: a privileged container writes to the host kernel, because these knobs have no namespace. I hit that same trap on a live server in who changed fs.file-max, which is why I only run experiments like this on a disposable VM.
On arm64 the mmap_rnd_bits default is 18. The measurement:
mmap_rnd_bits |
code span | mmap | stack |
|---|---|---|---|
| 18 | 2^18.0 | 2^18.0 | 2^18.0 |
| 24 | 2^24.0 | 2^24.0 | 2^18.0 |
| 33 | 2^33.0 | 2^33.0 | 2^18.0 |
The number you write really is the bit count — at all three values the span is exactly that power of two. The stack never moved; even at 33 it stayed at 18 bits. arm64's mask is 0x3ffff, and the source comment is explicit: "1GB of VA". When I tried to write 34, the kernel refused outright: write error: Invalid argument. The ceiling on arm64 with 48-bit VA and 4 KiB pages is 33.
Back to the oddity in the table. The code span came out as 2^18, but across a thousand samples there were only 969 distinct values; mmap had 998. Same span, different diversity. The birthday arithmetic answered immediately: 969 distinct values means about 31 collisions, which means 2^14 buckets. The code base was spread over an 18-bit range at 14-bit resolution. The lowest changing bit was 16, not 12 — aligned to 64 KiB.
The culprit is the linker, not the kernel. readelf -lW gives the binary away: on arm64 the PT_LOAD segments have a p_align of 0x10000, that is 64 KiB. The architecture does not dictate that; binutils' target default does — ELF_MAXPAGESIZE is 0x10000 in bfd/elfnn-aarch64.c and 0x1000 in bfd/elf64-x86-64.c. On the kernel side, after computing the PIE base, fs/binfmt_elf.c aligns it in one line: load_bias &= ~(alignment - 1) — where alignment is the largest p_align found by maximum_alignment(). The mask erases the bottom four random bits. I recompiled the same program with -Wl,-z,max-page-size=0x1000:
# p_align = 0x10000 (the arm64 default)
code distinct=969 span=2^18 lowest changing bit=16
# p_align = 0x1000
code distinct=998 span=2^18 lowest changing bit=12
998 distinct values matches the ~2 collisions expected for 2^18 buckets exactly. The four bits are back.
This is not an ARM quirk; the alignment line is architecture-independent and the loss is exactly log2(p_align / 4096). On x86_64 the default is 0x1000, so the loss is zero — the code's lowest changing bit measured 12. But an x86_64 binary built with -z max-page-size=0x200000 for large-page performance loses 9 bits to the same line. So the real question stops being about the architecture: how much of the kernel's entropy does the binary you ship actually use? The answer is one readelf away, and nobody tells you.
There is no such thing as "level 3"
The finding I saved for last. randomize_va_space looks like a level switch: 0, 1, 2. I pushed its limits.
# written=3 read back=3 -> measurement: identical to 2
# written=99 read back=99 -> measurement: identical to 2
# written=-1 read back=-1 -> measurement: heap glued to code, i.e. level 1
All three were accepted, all three were read back verbatim. 3 and 99 behave exactly like 2. Minus one behaves like level 1: mmap and stack randomized, heap fixed right after the code. The reason is in how the two knobs are registered: randomize_va_space is registered with proc_dointvec and no bounds at all (in today's kernel that lives in mm/memory.c; in 6.8 the same entry sat in kernel/sysctl.c, with identical values). mmap_rnd_bits, in mm/mmap.c, uses proc_dointvec_minmax with extra1/extra2 set to the architecture's MIN/MAX. One of them stops you at 34, the other says "sure" to 99.
| knob | file mode | handler | bounds |
|---|---|---|---|
kernel.randomize_va_space |
0644 | proc_dointvec |
none |
vm.mmap_rnd_bits |
0600 | proc_dointvec_minmax |
the arch's MIN/MAX |
Behaviourally the kernel treats anything greater than 1 as 2: heap randomization hangs on snapshot_randomize_va_space > 1, while mmap and the stack only face a "non-zero" test. Because the variable is a signed int, −1 fails the first condition and passes the second — the entire "level 1" behaviour I measured is in those two lines. The word snapshot is the trace of a patch: the ELF loader used to read this sysctl twice, so if the value changed in between, a process could be born half-randomized; since 6.11 it is read once per exec and pinned. The 6.8 I measured does not have that fix yet, but the logic of the conditions is the same.
The practical consequence: a hardening script that writes sysctl -w kernel.randomize_va_space=3 gets no error, sees 3 in the file, and feels good about itself. An audit tool that expects the value to be == 2 will raise an "ASLR disabled" alarm on the very same machine. Both are looking at the wrong thing. In my own checklist I now keep >= 2 plus a check on mmap_rnd_bits; without the number, the level means nothing.
Turning it to zero: the familiar addresses of debugging
For completeness I measured the opposite too. setarch -R sets ADDR_NO_RANDOMIZE on the process personality; since it does not touch anything system-wide, it is safe to run on a live server. Two hundred runs, one line:
0x5555555551c9 0x7ffff7fbc000 0x555555559000 0x7fffffffeaa4 7ffff7fc3000
0x5555555551c9 0x7ffff7fbc000 0x555555559000 0x7fffffffeaa4 7ffff7fc3000
0x555555555xxx and 0x7ffff7... — the addresses of every program that runs under gdb. gdb has done this by default for years, which is why the addresses you see while debugging are never the addresses in production. I learned how critical that difference can be in KSM wants two separate yeses: I had started four Node processes with setarch -R and pinned their heaps to the same address, yet the pages still refused to merge — because V8 generates its own random mmap hint, and disabling the kernel's ASLR is none of its business.
And this: all of ASLR rests on the assumption that addresses do not leak. I measured in detail how a single dmesg line or one error message can zero out both 32 bits and 18 bits in kptr_restrict, dmesg_restrict and perf_event_paranoid. Entropy is a delay, not a wall.
What to check on your own machines
The short list I took away from these measurements:
-
Ask for the bit count, not the level.
randomize_va_spaceonly tells you whether it is on.sudo cat /proc/sys/vm/mmap_rnd_bitsgives the real number; because it is 0600, your monitoring agent may not be collecting this metric at all unless it runs as root. The fix is not to run the agent as root but to carry the value out — a cron job writing into node_exporter's textfile directory is enough:
printf 'node_aslr_mmap_rnd_bits %s\n' "$(cat /proc/sys/vm/mmap_rnd_bits)" \
> /var/lib/node_exporter/textfile/aslr.prom.$$ \
&& mv /var/lib/node_exporter/textfile/aslr.prom.$$ \
/var/lib/node_exporter/textfile/aslr.prom
- Look at the architecture's ceiling. Ubuntu gives you 32 (the ceiling) on x86_64; a vanilla configuration gives 28 (the floor). On arm64 with 4 KiB pages and 48-bit VA the ceiling is 33 and the floor is 18 — a 15-bit gap, a factor of 32 thousand.
-
Check
p_alignin every binary, not only the ARM ones. Ifreadelf -lW binary | grep LOADshows 0x10000, your PIE base is using one sixteenth of the entropy the kernel offers; 0x200000 on x86_64 costs 9 bits.-z max-page-size=0x1000gets them back, but on setups that want large-page performance that is a deliberate trade-off. - Compute the conditional entropy. What is left after a single leak? On the x86_64 I measured, leaking the code drops the heap to 18 bits; on 6.8, leaking the stack dropped the vdso to 9; on 7.0, leaking anything in the mmap region drops the vdso to zero. The weakest link says more than the average.
- Do not assume across versions, and do not assume across distributions either. Same binary, same architecture, two kernels: the vdso behaves differently. On top of that, two kernels both calling themselves "6.8" may not do the same thing — Ubuntu's diverged from upstream on the heap window. Write the kernel you measured next to any ASLR sentence you put in a checklist.
-
Know which levers you actually have. On Ubuntu
mmap_rnd_bitsis already at the ceiling; the stack's 22 bits and the heap's 18 are compile-time constants with no knob to turn. That leaves two real levers: thep_alignof your own binaries, and not leaking addresses. If that is what the measurement says, the work to do is not sysctl tuning. - There is no per-pod hardening in containers. These knobs have no namespace, so an unprivileged container inherits the host's value verbatim and cannot change it, while a privileged one changes the host's. "Let's tighten ASLR for that pod" is not a thing; the decision is made at node level.
What this dig left me with was not a number; it was a changed habit. Hardening checklists ask binary questions — on or off, compliant or not — and answering them feels reassuring. Yet most security settings are not binary but scalar, and the only way to learn where you stand on the scale is to measure. The line randomize_va_space = 2 was correct on my servers all along. It just wasn't saying what I thought it said.
Official Sources
- Linux admin guide — kernel sysctl: randomize_va_space · vm sysctl: mmap_rnd_bits, mmap_rnd_compat_bits
- arch/Kconfig — ARCH_MMAP_RND_BITS defaults to MIN · arch/x86/Kconfig — MIN 28 / MAX 32 · arch/arm64/Kconfig — MIN 18 / MAX 33 depending on page size
- mm/memory.c (master) — randomize_va_space and its unbounded proc_dointvec registration · mm/mmap.c (master) — mmap_rnd_bits, mode 0600 and proc_dointvec_minmax
- fs/binfmt_elf.c (master) — load_bias, maximum_alignment and snapshot_randomize_va_space
- arch/x86/kernel/process.c (master) — arch_randomize_brk SZ_1G and arch_align_stack · arch/x86/include/asm/elf.h (master) — STACK_RND_MASK 0x3fffff
-
arch/x86/entry/vdso/vma.c (master) — map_vdso now uses get_unmapped_area — for 6.8's
vdso_addr()and the 32 MiB brk window, see the version-pinned links in the body
Top comments (0)