-
-
Notifications
You must be signed in to change notification settings - Fork 608
random: reseed the CSPRNG on hypervisor resume so snapshot clones diverge #1461
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,10 @@ | |
| #include <osv/sched.hh> | ||
| #include <mutex> | ||
| #include <atomic> | ||
| #include <osv/kernel_config_core_reseed_on_resume.h> | ||
| #if CONF_core_reseed_on_resume | ||
| #include "drivers/random.hh" | ||
| #endif | ||
|
|
||
| using namespace osv::clock; | ||
|
|
||
|
|
@@ -65,9 +69,51 @@ kvmclock::kvmclock() | |
| // | ||
| // Start a thread that will synchronize the wall clock with the host | ||
| auto t = sched::thread::make([this] { | ||
| #if CONF_core_reseed_on_resume | ||
| // Track the guest system_time across each 1 second sleep. When the | ||
| // guest is paused and later resumed from a hypervisor snapshot, the | ||
| // system clock advances by far more than the second we slept. That | ||
| // large forward discontinuity is our resume signal: on resume we | ||
| // force the CSPRNG to re-key so that two guests restored from the | ||
| // same snapshot do not keep emitting identical OS random output. | ||
| // A normal (never-resumed) guest only ever sees ~1 second here and | ||
| // so never triggers the reseed, leaving its behavior unchanged. | ||
| // | ||
| // This 1Hz thread is the PROACTIVE resume detector: it fires within | ||
| // ~1.5s of a resume even if nothing ever reads /dev/random, which is | ||
| // what covers in-kernel CSPRNG consumers (arc4random()/read_random() | ||
| // for TCP initial sequence numbers, etc.) that never touch the | ||
| // /dev/random read path. A second, low-latency detector on that read | ||
| // path (reseed_if_resumed() in drivers/random.cc) closes the up-to- | ||
| // ~1.5s window for a program that reads /dev/random immediately after | ||
| // resume, before this thread's next tick. | ||
| // | ||
| // Both detectors are heuristics based on a clock discontinuity. The | ||
| // robust mechanism used by Linux/Firecracker is the VMGenID device | ||
| // (an ACPI-exposed generation counter the hypervisor bumps on clone), | ||
| // which detects a clone with no timing guess and covers sub-second | ||
| // clones this heuristic could miss. Adding a VMGenID driver is a | ||
| // worthwhile future enhancement; until then this clock-jump heuristic | ||
| // is a pragmatic first step that is much better than never reseeding. | ||
| u64 prev_system_time = this->system_time(); | ||
| #endif | ||
| while (true) { | ||
| sched::thread::sleep(std::chrono::seconds(1)); | ||
| #if CONF_core_reseed_on_resume | ||
| u64 now_system_time = this->system_time(); | ||
| // We slept 1 second. Allow slack for scheduling delay, but treat | ||
| // a jump well beyond that (more than 1.5 seconds) as a hypervisor | ||
| // resume: the guest was paused across the snapshot and the system | ||
| // clock kept advancing while it was suspended. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are we worried that if a clone operation takes less than 1.5 seconds, we won't notice it? I was curious how Linux handles it, and apparently it uses something called VMGenID (https://github.com/firecracker-microvm/firecracker/blob/main/docs/snapshotting/snapshot-support.md#reusing-snapshotted-states-securely) to figure out the VM changed and a reseeding is necessary. In any case, I think your patch is better than nothing, I'm not sure what is the best approach.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, continuing to read the code. I see that below you solved that "during roughly 1 second" problem by having yet another place where a resume is detected. Maybe you should mention that second place in the comment here?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks - VMGenID is indeed the proper mechanism, and I have noted it in the code as the right future enhancement. Doing it well means an ACPI-exposed VMGenID device driver (the guest reads a generation counter the hypervisor bumps on clone), which is a larger, separate piece of work; this clock-jump heuristic is the pragmatic first step that is, as you said, better than nothing. On the two timing worries: a clone shorter than 1.5s could indeed slip past the kvmclock detector, but the read-path detector in random.cc catches the first read after resume regardless of how brief the pause was (see the follow-up below). And the "~1 second still using the cloned seed" window is exactly what the random.cc detector closes for /dev/random consumers. I updated this comment to say all of that (VMGenID as the ideal, plus a pointer to the second detector). |
||
| if (now_system_time > prev_system_time && | ||
| (now_system_time - prev_system_time) > 1500000000ULL) { | ||
| randomdev::reseed_on_resume(); | ||
| } | ||
| #endif | ||
| this->sync_wall_clock(); | ||
| #if CONF_core_reseed_on_resume | ||
| prev_system_time = this->system_time(); | ||
| #endif | ||
| } | ||
| }, sched::thread::attr().name("kvm_wall_clock_sync")); | ||
| t->start(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,14 +39,26 @@ | |
| #include <osv/device.h> | ||
| #include <osv/uio.h> | ||
| #include <osv/debug.hh> | ||
| #include <osv/clock.hh> | ||
| #include <atomic> | ||
| #include <osv/kernel_config_core_reseed_on_resume.h> | ||
|
|
||
| #include <dev/random/randomdev.h> | ||
| #include <dev/random/randomdev_soft.h> | ||
| #include <dev/random/random_adaptors.h> | ||
| #include <dev/random/random_harvestq.h> | ||
| #include <dev/random/live_entropy_sources.h> | ||
|
|
||
| #ifdef __x86_64__ | ||
| #include "processor.hh" | ||
| #endif | ||
|
|
||
| namespace randomdev { | ||
|
|
||
| #if CONF_core_reseed_on_resume | ||
| void reseed_on_resume(); | ||
| #endif | ||
|
|
||
| struct random_device_priv { | ||
| random_device* drv; | ||
| }; | ||
|
|
@@ -56,12 +68,60 @@ static random_device_priv *to_priv(device *dev) | |
| return reinterpret_cast<random_device_priv*>(dev->private_data); | ||
| } | ||
|
|
||
| #if CONF_core_reseed_on_resume | ||
| // Low-latency, read-path half of the hypervisor-resume CSPRNG reseed described | ||
| // at reseed_on_resume() below. The problem it solves: a full-VM snapshot | ||
| // captures the entropy pool and CSPRNG state, so two guests restored from the | ||
| // same snapshot would replay an identical OS random stream (duplicate session | ||
| // keys, TCP sequence numbers, UUIDs) until something forces a re-key. | ||
| // | ||
| // Resume is detected in two independent places: | ||
| // 1. drivers/kvmclock.cc, in the 1Hz "kvm_wall_clock_sync" thread: a proactive | ||
| // detector that fires within ~1.5s of resume even if nothing ever reads | ||
| // /dev/random. This is the one that covers in-kernel CSPRNG consumers | ||
| // (arc4random()/read_random() for TCP ISNs etc.) which never enter this | ||
| // read path. | ||
| // 2. here, in random_read(): a reactive detector that fires immediately on | ||
| // the first /dev/random or getrandom() read after resume, closing the up | ||
| // to ~1.5s window in which detector 1 has not fired yet. | ||
| // | ||
| // We compare the monotonic uptime clock against the value seen at the previous | ||
| // read. Between two back-to-back reads uptime advances by microseconds; a jump | ||
| // far larger than any plausible gap between reads (>1.5s, matching the kvmclock | ||
| // detector's threshold: this code exists to detect the same event sooner, not | ||
| // at a lower threshold) means the guest was paused across a snapshot and | ||
| // resumed, so we re-key before serving. Reseeding is skipped on the hot read | ||
| // path (back-to-back reads never exceed the threshold) and, in the rare case it | ||
| // does fire after a long idle with no resume, an extra re-key is harmless. | ||
| static std::atomic<u64> _last_read_uptime{0}; | ||
| // Set true once randomdev_init() has brought the device (and the harvest ring) | ||
| // up, so reseed_on_resume() is a true no-op if a resume is somehow detected | ||
| // before that (e.g. with --norandom). | ||
| static std::atomic<bool> _reseed_ready{false}; | ||
|
|
||
| static void reseed_if_resumed() | ||
| { | ||
| u64 now = (u64)::clock::get()->uptime(); | ||
| u64 prev = _last_read_uptime.exchange(now, std::memory_order_relaxed); | ||
| // Skip the very first read (prev == 0) and only act on a large forward jump. | ||
| // 1.5s matches the kvmclock detector; the purpose of this read-path check is | ||
| // faster detection of the same resume event, not a lower threshold. | ||
| if (prev != 0 && now > prev && (now - prev) > 1500000000ULL) { | ||
| reseed_on_resume(); | ||
| } | ||
| } | ||
| #endif | ||
|
|
||
| static int | ||
| random_read(struct device *dev, struct uio *uio, int ioflags) | ||
| { | ||
| int c, error = 0; | ||
| char random_buf[PAGE_SIZE]; | ||
|
|
||
| #if CONF_core_reseed_on_resume | ||
| reseed_if_resumed(); | ||
| #endif | ||
|
|
||
| // Blocking logic | ||
| if (!random_adaptor->seeded) { | ||
| error = (*random_adaptor->block)(ioflags); | ||
|
|
@@ -206,6 +266,74 @@ void randomdev_init() | |
| { | ||
| new random_device(); | ||
| debugf("random: <%s> initialized\n", random_adaptor->ident); | ||
| #if CONF_core_reseed_on_resume | ||
| _reseed_ready.store(true, std::memory_order_release); | ||
| #endif | ||
| } | ||
|
|
||
| #if CONF_core_reseed_on_resume | ||
| // Force the CSPRNG to re-key after a hypervisor resume so that two guests | ||
| // restored from the SAME snapshot do not keep producing identical OS random | ||
| // output. A full-VM snapshot captures the entire entropy pool and CSPRNG state, | ||
| // so without an explicit reseed every clone would replay the exact same random | ||
| // stream. This is a real correctness and security problem for a cloned fleet | ||
| // (duplicated session keys, TCP sequence numbers, UUIDs, and so on). | ||
| // | ||
| // We mix in values that are guaranteed to differ between two clones even when | ||
| // there is no live hardware entropy source (no RDRAND, no virtio-rng): the | ||
| // wall-clock time the hypervisor handed us on resume differs per clone because | ||
| // each clone is resumed at a distinct host wall-clock instant, and the TSC on | ||
| // resume differs as well. We feed that unique material into the harvest queue | ||
| // as DIVERGENCE material only (bits == 0), so it is hashed into the pool to | ||
| // force the two clones apart but is NOT credited as entropy - it must never be | ||
| // able to advance Yarrow's counters or unblock /dev/random on its own, since it | ||
| // is predictable low-quality data, not real entropy. RDRAND/RDSEED and any | ||
| // virtio-rng source continue to feed the pool as before. After mixing we | ||
| // command an explicit reseed so the re-key takes effect immediately rather than | ||
| // only after the next periodic harvest round. | ||
| // | ||
| // This runs ONLY when a resume has actually been detected (see the two callers: | ||
| // the 1Hz "kvm_wall_clock_sync" thread in drivers/kvmclock.cc, and | ||
| // reseed_if_resumed() on the /dev/random read path above), so a normally- | ||
| // running or freshly-booted guest that is never resumed follows exactly the | ||
| // same code path as before. | ||
| void reseed_on_resume() | ||
| { | ||
| // No-op until the random device has actually been initialized. random_adaptor | ||
| // is always non-null (it points at the static soft CSPRNG context), so that | ||
| // alone is not enough: with --norandom, or if a resume were somehow detected | ||
| // before randomdev_init() ran, the harvest ring would not exist yet and | ||
| // random_harvestq_internal() would dereference it. _reseed_ready is set true | ||
| // only at the end of randomdev_init(), after random_harvestq_init(). | ||
| if (!random_adaptor || !_reseed_ready.load(std::memory_order_acquire)) { | ||
| return; | ||
| } | ||
|
Comment on lines
+300
to
+310
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point - I will tighten the guard to also confirm the adaptor has been initialized (not just that random_adaptor is non-null) so reseed_on_resume() is a true no-op if called before the device is up.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tightened. Since random_adaptor is always non-null (it points at the static soft CSPRNG context), that check alone was insufficient. reseed_on_resume() now also gates on a new _reseed_ready flag that is set true only at the end of randomdev_init(), after random_harvestq_init() has created the harvest ring. So with --norandom, or if a resume were somehow detected before the device is up, it is a true no-op instead of dereferencing an uninitialized ring. |
||
|
|
||
| // Per-resume unique material. Each field differs between two clones that | ||
| // were resumed from the same snapshot at different host wall-clock instants. | ||
| struct { | ||
| u64 wall_ns; | ||
| u64 tsc; | ||
| u64 uptime_ns; | ||
| } seed; | ||
| seed.wall_ns = (u64)::clock::get()->time(); | ||
| seed.uptime_ns = (u64)::clock::get()->uptime(); | ||
| #ifdef __x86_64__ | ||
| seed.tsc = processor::rdtsc(); | ||
| #else | ||
| seed.tsc = seed.uptime_ns; | ||
| #endif | ||
|
|
||
| // Mix the unique material in with a zero entropy-bit credit (divergence, not | ||
| // entropy), then force an explicit reseed so the re-key is effective before | ||
| // the next read. Any live hardware source (RDRAND / virtio-rng) is drained | ||
| // by the reseed itself. | ||
| random_harvestq_internal(seed.tsc, &seed, sizeof(seed), | ||
| 0, RANDOM_PURE_RDRAND); | ||
| if (random_adaptor->reseed) { | ||
| (random_adaptor->reseed)(); | ||
| } | ||
| } | ||
| #endif | ||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After reading the full patch and realizing you have a second detection mechanism in the /dev/random implementation in random.cc, that can detect the event immediately instead of after 1 second, I wonder - perhaps this code, in kvmclock.cc, isn't needed at all and can be removed?
If you didn't have the code in random.cc, maybe this code might be needed, but still you did do that change in random.cc, why do this change too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question, and I looked hard at removing it. I decided to keep it, because random.cc alone leaves a real gap: it only detects a resume when something reads the /dev/random device (random_read()). But the CSPRNG also has in-kernel consumers that never go through that path - arc4random()/read_random() in the BSD stack, used for TCP initial sequence numbers (bsd/sys/netinet/tcp_subr.cc:1551), PCB/syncache, etc. A guest that is snapshotted while idle on /dev/random and then, after resume, only does network activity would generate ISNs from the still-cloned CSPRNG and never trigger the read-path detector.
The kvmclock 1Hz thread closes that gap: it fires within ~1.5s of resume regardless of whether anyone reads /dev/random, so those in-kernel consumers get divergence too. So the two are complementary: kvmclock guarantees an upper bound on time-to-reseed for the no-read case; random.cc makes it immediate on the read path. I added a comment here explaining that split (and cross-referencing the random.cc detector, per your other note).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting, but doesn't mean that those "in-kernel readers" also need exactly the same logic - if an in-kernel reader reads more than 0.5 second (or 1.5 second) after the previous read, we need the reseed? Maybe the "read path detector" needs to be in a lower-level read function?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good push - it made me look more carefully, and the honest answer is that a lower-level read detector would not actually help the in-kernel consumers, for a reason specific to how OSv wires up its RNGs.
There are two independent RNG subsystems here, not one:
The
/dev/randomdevice CSPRNG (the Yarrow/softrandom_adaptorindrivers/random.cc+bsd/sys/dev/random/). This is the onereseed_on_resume()re-keys, and its only read entry point israndom_read()- so the read-path detector already sits at the lowest-level read function for this pool.The in-kernel
arc4random()/read_random()path used for TCP ISNs (bsd/sys/netinet/tcp_subr.cc:1551), PCB/syncache, etc. On OSv this does NOT draw from the Yarrow CSPRNG at all:read_random()links to the stub inbsd/sys/libkern/arc4random.c:38(agetmicrotime()-based FIXME), andarc4random()runs its own arc4 S-box seeded from that stub. The#ifndef __OSV__hook that would let the device CSPRNG "do arc4random a favour" (randomdev_soft.cc:228) is compiled out.So putting the resume-check inside
read_random()/arc4random()would not reseed the pool thatreseed_on_resume()re-keys - it would reseed the wrong (and much weaker) thing, and it would put a per-call timestamp compare on the ISN hot path (arc4random is called per connection). Neither is what we want.That is exactly why I kept the kvmclock 1Hz detector: it is the one mechanism that fires on a wall-clock discontinuity regardless of which RNG a consumer uses or whether anything reads at all, so it covers the network-only-after-resume case without instrumenting every low-level RNG call. The read-path detector in
random_read()is then just the fast-path optimization for the device CSPRNG, and it is already at the lowest-level read for that pool.(Correcting my earlier reply: I said the in-kernel readers use "the still-cloned CSPRNG" - more precisely they use a separate arc4 RNG that the device reseed does not touch, which strengthens rather than weakens the case for keeping the clock-jump detector. Properly reseeding that arc4 path on resume, or better, unifying it onto the device CSPRNG, is a worthwhile follow-up but orthogonal to this patch.)