DEV Community

Cover image for I Read 1.5 GiB of Random Data — the Kernel Froze the Counter in v5.19
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Read 1.5 GiB of Random Data — the Kernel Froze the Counter in v5.19

There is a reflex I carried for years as a sysadmin: the moment a new server
came up, I typed cat /proc/sys/kernel/random/entropy_avail. If the number
looked low, I installed haveged; if it climbed, I relaxed. The story behind
the reflex went like this — the kernel keeps an entropy pool, reading
/dev/random drains it, and once it is drained your TLS handshakes start
waiting.

While writing this piece the reflex finally got measured instead of trusted.
On the server that hosts this blog I read 1.5 GiB from /dev/random and
sampled the counter at 40 points. The number never twitched: 256, 256, 256.
Then I opened the kernel source and saw why — entropy_avail is no longer a
gauge of "how much entropy I have on hand" but an initialisation counter that
fills at boot and then freezes.

There are two separate changes here, and for a long time I took them for one.
The pool ceiling dropped from 4096 bits to 256 bits in v5.18. The counter
giving up draining — the actual freeze — happened one release later, in
v5.19. I noticed that distinction halfway through this article, while
reading the source; below I show the two separately.

Where I tried to drain the pool

Every measurement here was taken on the machine that runs the blog, on Ubuntu
24.04.4 with kernel 6.8.0-147-generic. First I started a background loop
sampling entropy_avail every 50 ms, then drained /dev/random at the same
time:

S=$(mktemp)
( for i in $(seq 1 40); do printf "%s " "$(cat /proc/sys/kernel/random/entropy_avail)"; sleep 0.05; done; echo ) > "$S" &
dd if=/dev/random of=/dev/null bs=1M count=256 iflag=fullblock
wait
echo "ornekleyici: $(cat $S)"
echo "ornek sayisi: $(wc -w < $S) | benzersiz deger: $(tr ' ' '\n' < $S | sort -u | tr -d '\n')"
Enter fullscreen mode Exit fullscreen mode

Here is the whole output (the labels are Turkish: sampler, sample count,
distinct value):

268435456 bytes (268 MB, 256 MiB) copied, 1.42559 s, 188 MB/s
ornekleyici: 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256 256
ornek sayisi: 40 | benzersiz deger: 256
Enter fullscreen mode Exit fullscreen mode

The last line is the whole story: the set of distinct values across 40 samples
has exactly one element. To be honest about it, the sampler outlived the read —
each turn forks a cat, 40 turns take more than two seconds, and dd finished
in 1.43. So the last quarter of the samples measured an idle system. The first
~28 samples, taken while the read was running, give the same 256, so the
conclusion holds — but presenting all 40 as "under load" would be wrong.

I followed that with a single 512 MiB pull (200 MB/s), then three more rounds
for the throughput measurements below. At the end of 1.5 GiB read from
/dev/random (256 + 512 + 3 × 256 MiB) the counter still read 256.

When you try to empty a tank and the "level indicator" never drops, there are
two possibilities: either the tank is infinite, or the gauge you are reading is
not connected to it. The source says it is the second one.

What the counter is actually wired to

Every quote below comes from the release the measurements ran on, v6.8. I
say that explicitly because mainline is at 7.4-rc1 today and the code has moved since —
proc_do_rointvec now takes a const struct ctl_table *, for instance. The
source links at the end point at the current tree; that is why you may see a
difference.

The sysctl table in drivers/char/random.c binds the entropy_avail file to
this field:

{
    .procname    = "entropy_avail",
    .data        = &input_pool.init_bits,
    .maxlen      = sizeof(int),
    .mode        = 0444,
    .proc_handler = proc_dointvec,
},
Enter fullscreen mode Exit fullscreen mode

The field is called init_bits — initialisation bits. The function that raises
it is _credit_init_bits(), and the main road to it runs through a macro gate:

#define credit_init_bits(bits) if (!crng_ready()) _credit_init_bits(bits)
Enter fullscreen mode Exit fullscreen mode

Once the generator is ready that macro does nothing. There are also two direct
calls that bypass it (the trust_cpu path and add_timer_randomness()), so
saying "the macro is the only way in" would be wrong. What guarantees the freeze
seen above is not the gate but the ceiling inside the increment itself:

orig = READ_ONCE(input_pool.init_bits);
do {
    new = min_t(unsigned int, POOL_BITS, orig + add);
} while (!try_cmpxchg(&input_pool.init_bits, &orig, new));
Enter fullscreen mode Exit fullscreen mode

orig + add, capped at POOL_BITS — and POOL_BITS is 256. No subtraction. I
could not find a single line in the whole file that lowers init_bits; nothing
on the /dev/random read path touches the field. Reads failing to drain the
counter is not an optimisation, it is the design.

Diagram

I could measure how short the counter's life is, too — just not where I
expected. journalctl -k returns zero hits for crng, the dmesg ring has
wrapped, and journald's oldest record is 28 October. My first draft said the
evidence had evaporated. Wrong: the rotated syslog archive had kept it.

$ sudo zgrep -h crng /var/log/syslog* /var/log/kern.log*
2026-10-18T18:13:38.620522+03:00 mustafaerbay-vps3 kernel: random: crng init done
Enter fullscreen mode Exit fullscreen mode

The server booted at 2026-10-18 18:13:28. So the counter reached 256 about
ten seconds after boot and has been sitting there for sixteen days. I had
measured the log window before:
my journal that I set to seven days keeping six days and eighteen hours
found journald's window short and zgrep's long. The same split paid off here:
journald forgot, syslog remembered.

How 4096 became 256, and when the counter froze

The old story was not invented; it belonged to a different kernel. I traced the
POOL_BITS constant release by release. In v5.17 the pool was still the classic
LFSR pool:

enum poolinfo {
    POOL_WORDS = 128,
    POOL_WORDMASK = POOL_WORDS - 1,
    POOL_BYTES = POOL_WORDS * sizeof(u32),
    POOL_BITS = POOL_BYTES * 8,
Enter fullscreen mode Exit fullscreen mode

128 words × 4 bytes × 8 = 4096 bits. Exactly the number the man page quotes.
In v5.18 the same place collapses to one line:

POOL_BITS = BLAKE2S_HASH_SIZE * 8,
POOL_MIN_BITS = POOL_BITS /* No point in settling for less. */
Enter fullscreen mode Exit fullscreen mode

The BLAKE2s digest is 32 bytes, so 256 bits. The Arch Wiki describes the
same 256 as the size of a ChaCha20 key in bits; not a contradiction, the same
number by design — the pool's digest is what feeds that key.

But v5.18 did not freeze the counter. In that release entropy_avail was still
bound to input_pool.entropy_count, and that field really did drain:

} while (cmpxchg(&input_pool.entropy_count, entropy_count, 0) != entropy_count);
Enter fullscreen mode Exit fullscreen mode

init_bits, the macro gate and the threshold-free random_poll arrive one
release later, in v5.19. Laid out as a table, the two come apart:

Kernel tag Pool ceiling entropy_avail bound to
v5.17 4096 bits entropy_count (drains)
v5.18 256 bits entropy_count (drains)
v5.19 256 bits init_bits (freezes)
5.10.118 (stable) 4096 bits entropy_count (drains)
5.10.119 (stable) 256 bits init_bits (freezes)
5.10.273 (stable) 256 bits init_bits (freezes)

The stable row is the most interesting one: between .118 and .119 the 5.10
series swallowed two mainline releases at once. What took two steps upstream
happened in a single patch set on the long-term branch.

That makes "I'm on an old kernel, I still see 4096" hard to say — but not
impossible. The long-lived 4.x kernels of enterprise distributions still carry
the old pool: in both the 4.15 and the 4.18 source, sysctl_poolsize is
INPUT_POOL_WORDS * 32, i.e. 4096. Machines running those kernels still receive
security updates today. I cannot speak for each vendor's own backport decisions,
so my only firm advice is this: trust your own poolsize output, not the claim.

Two knobs that accept writes and ignore them

Two writable files sit in the same directory. I wrote different values to both:
1024 to write_wakeup_threshold, 999 to urandom_min_reseed_secs. Both
commands returned successfully, and both files then read 256 and 60 again.
sysctl goes one step further:

$ sudo sysctl -w kernel.random.write_wakeup_threshold=1024
kernel.random.write_wakeup_threshold = 1024

$ sudo sysctl kernel.random.write_wakeup_threshold
kernel.random.write_wakeup_threshold = 256
Enter fullscreen mode Exit fullscreen mode

The reason is proc_do_rointvec — a handler that returns 0, meaning success,
on a write and does nothing; the comment opening the sysctl section introduces
these knobs as "partly unused legacy knobs with dummy values to not break
userspace".

I will not dwell on this part, because I already measured the same pattern on
this blog once:
writing ten values to tcp_adv_win_scale and watching the window stay at 31,856
was exactly that story — a documented, writable lever the kernel had stopped
reading. What is new here is not the lever: entropy_avail is not a setting
but a read-only indicator. Turning a dead lever does nothing; looking at a
dead gauge makes you believe you saw something. The first is ineffective, the
second is misleading — and monitoring systems attach to the second.

The same illusion continues on the ioctl side:

RNDGETENTCNT        -> 256
RNDADDTOENTCNT(-64) -> Invalid argument
RNDADDTOENTCNT(+64) -> succeeded
RNDZAPENTCNT        -> succeeded
RNDGETENTCNT after  -> 256
Enter fullscreen mode Exit fullscreen mode

The man page describes RNDADDTOENTCNT as "increment or decrement";
decrementing is now EINVAL (a two-line condition in the source rejects
negative values). RNDZAPENTCNT — literally "zap the count" — returns success
and the counter stays at 256; the source comment says as much: No longer has
any effect.

The difference between the two devices shrank to the boot window

The second half of the old model went: /dev/urandom is fast but "weak",
/dev/random is "strong" but blocks once entropy runs out. Three rounds,
256 MiB each:

Round /dev/random /dev/urandom
1 199 MB/s 198 MB/s
2 213 MB/s 165 MB/s
3 189 MB/s 191 MB/s

The medians are 199 and 191 MB/s; the ranges (189–213 against 165–198) overlap,
so I will not claim one is faster than the other. The two /dev/random reads
above (188 and 200 MB/s) fall in the same band. The only conclusion I can draw:
there is no order-of-magnitude gap, and /dev/random did not block at any point.

The syscall side agrees: over 10,000 calls each, getrandom(0) took 1.07 µs and
getrandom(GRND_RANDOM) 1.04 µs, and after 20,000 calls entropy_avail was
still 256.

But saying "the difference is gone entirely" would be wrong, because the source
does not say that. While the generator is not ready the two devices still
diverge:

if (!crng_ready() &&
    ((kiocb->ki_flags & (IOCB_NOWAIT | IOCB_NOIO)) ||
     (kiocb->ki_filp->f_flags & O_NONBLOCK)))
    return -EAGAIN;

ret = wait_for_random_bytes();
Enter fullscreen mode Exit fullscreen mode

That is random_read_iter. Its /dev/urandom counterpart, urandom_read_iter,
does not wait; it prints a rate-limited warning and hands over the data. So the
difference did not disappear, it collapsed into the boot window: after the
generator is ready the two devices are the same, before that they are not. On my
server that window lasted ten seconds and has been closed for sixteen days. For
a service that generates keys early in boot, those ten seconds are a real delay.

The knobs that actually set the window's length live here too:
random.trust_cpu and random.trust_bootloader, both boot parameters, both
enabled by default. When the CPU's random instruction is trusted the counter
fills in one move; turned off, or on a platform without hardware support, the
window can stretch into minutes. That is the one place where the
haveged/rngd discussion still makes sense today — RNDADDENTROPY really
does credit entropy, and that is exactly the path rngd uses. On a desktop or a
modern server CPU, installing it buys nothing.

Why the documentation still tells the old story

I ran man 4 random on my server — the distribution's own documentation,
shipped by Ubuntu's manpages package:

       entropy_avail
              This  read-only file gives the available entropy, in bits.  This
              will be a number in the range 0 to 4096.
...
       read_wakeup_threshold
              This  file  contains  the number of bits of entropy required for
              waking  up  processes  that  sleep  waiting  for  entropy   from
              /dev/random.  The default is 64.
Enter fullscreen mode Exit fullscreen mode

The same page also says poolsize "contains the value 4096". Three claims, all
three wrong on that same machine: the range is not 0–4096, poolsize is not
4096, and read_wakeup_threshold does not exist as a file — the six files in
the directory are boot_id, entropy_avail, poolsize,
urandom_min_reseed_secs, uuid and write_wakeup_threshold.

The kernel's own admin-guide/sysctl/kernel.rst is more honest but stops
halfway. It calls urandom_min_reseed_secs obsolete and adds, for both knobs,
that the file is writable for compatibility purposes but writing to it has no
effect on any RNG behavior. Yet entropy_avail is dispatched in a single line:
"the pool's entropy count, in bits". Technically not a lie; because the word
"count" implies something that varies, it walks the reader into the wrong model.

I found the current answer not in a distribution manual but in the Arch Wiki,
which states plainly that the file should always contain 256, that this is the
size of a ChaCha20 key in bits, and that historical documentation expecting
larger values — or expecting the user to act when the value gets "too low" — can
be disregarded.

Why the alert never fires

Noticing that the blog's infrastructure has no entropy-related line at all, I
searched my own machine: across the 46 git repositories under ~/IdeaProjects,
in script, configuration and documentation extensions, zero hits for
entropy_avail|haveged|rng-tools|rngd. Neither package is installed on the
server either. So this piece is not fixing a defect in my own stack; it is
retiring a reflex.

I remember the common runbook pattern: alert if entropy_avail drops below
1000.
Here is what that amounts to today — threshold 1000, ceiling 256. The
condition is true every single moment the server is up. Build that alert and it
either screams from the first second and gets muted, or sits green on a panel
and is never questioned. Neither is monitoring.

I wrote about how my alerts can stay silent at the level of a single word in
seven cancelled runs escaping an alert that recognises one word;
this is the deeper version. There the alert's pattern was incomplete; here the
quantity it measures has ceased to exist.

There is one more trap on the container side: random_table is registered
globally, not per network or user namespace. The entropy_avail you read inside
a container reflects the host's generator, not the container's — my own blog
container reads the same 256 as the host. I had seen container and host sysctls
bleeding into each other before, in
the measurement where a neighbouring container shrank my pipe buffers eightfold;
the direction is reversed here, but the lesson is the same: there is no such
thing as a per-container entropy indicator.

I split the practical consequences like this:

  • Bin it: entropy_avail threshold alerts, poolsize graphs, write_wakeup_threshold and urandom_min_reseed_secs settings, per-container entropy panels.
  • Still meaningful: when the crng init done line appears in the boot log. Not the counter's value — the moment it reaches 256 is the only thing that matters. Collect that line before journald's window rotates; mine did, and zgrep saved it.
  • It depends: haveged/rngd. Pointless on a machine with a CPU random instruction; genuinely shortens the boot window on a platform booted with random.trust_cpu=0 or without hardware support.
  • Still a real risk: cloning virtual machine images. Open the same disk on two machines and both start from the same saved seed; the fix is not an entropy daemon but removing random-seed from the image and exposing a hardware random device through the virtualisation layer.
  • For auditors: if a FIPS or compliance reviewer asks whether the entropy pool is monitored, your answer should be the boot log, not a threshold graph. Writing that distinction down before the audit is easier than arguing it during one.
  • In code: calling getrandom() without GRND_RANDOM is enough.

Checklist

To verify this on your own server in five minutes:

  1. cat /proc/sys/kernel/random/poolsize → if it says 256 you are on the new behaviour; if it says 4096 you are on an old-pool kernel and most of the below does not apply to you.
  2. ls /proc/sys/kernel/random/ → if read_wakeup_threshold is missing, your man page is older than your system.
  3. Sample entropy_avail while running dd if=/dev/random of=/dev/null bs=1M count=512 iflag=fullblock; if it does not move, the indicator is a constant and not a measurement.
  4. Try sudo sysctl -w kernel.random.write_wakeup_threshold=1024, then cat the file — look at the file, not at sysctl's output.
  5. Grep your monitoring configuration for entropy; delete every threshold rule it turns up and replace it with a crng init done check in the boot log.
  6. sudo zgrep -h crng /var/log/syslog* /var/log/kern.log* → the real length of your boot window is in there.

Do not skip steps four and five. Steps one and two only tell you your kernel is
new; whether the stale setting is still sitting in your own files is shown only
by steps four and five.

What remains

The lesson I take from this is less about entropy than about indicators. A
number can be read from the same place for years, keep the same name and sit in
the same dashboard box — while the mechanism beneath it has changed. And because
of compatibility concerns nothing breaks: the file is there, reads work, writes
return success. The only thing that breaks is the indicator's meaning.

My own mistake was a good example of it. I started out writing "frozen since
v5.18"; taking two separate changes for one is exactly how the wrong mental
model gets built. Had I not traced the version numbers in the source, this piece
would have made the very error it warns about.

So before I put an indicator on a dashboard now I ask one question: can I do
something that changes this number and then see the result? If the answer is no,
that panel holds an ornament, not a measurement.

Official Sources

Top comments (0)