Skip to content

Build1 publisher2 min readPublished

Restoring CPython from a memory snapshot would freeze its randomized hash seed

Hood Chatham's Pyodide tests ran Hello, world about 4x faster from a memory snapshot, but every restored process would share one hash seed. He wants Python to add an initialization phase that runtimes and libraries can re-run to put the randomness back.

The Engineer · Build desk

Illustration accompanying Restoring CPython from a memory snapshot would freeze its randomized hash seed

What happened

  • Node.js added V8 snapshots in v4.2.4 for about 33% better startup time, then disabled them in v4.8.4 over hash-collision denial of service.
  • Python has salted hash() with a random value drawn at startup since 3.3, so remote attackers cannot force unbalanced dict and set structures.
  • Chatham cited RPython and SPy, runtimes with an explicit entry point to an initialization phase, used there for tree-shaking and pre-evaluation.
  • Stefan Behnel pointed to the atexit module and asked whether a matching reinit callback could meet the same need.
  • Thomas Wouters raised PEP 810 lazy imports, due in Python 3.15, and Chatham said they might make his use case worse before it gets better.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • precedent Node.js has already shipped snapshots and then switched them off over this flaw, so a CPython version would need seed re-drawing on restore in its first release.
  • exposure Libraries and programs that assume fresh randomness at startup would hand identical values to every restored process, and Chatham said such cases likely exist beyond hash().
  • constraint A re-run initialization phase restores randomness only in code that hooks into it, so snapshot safety would depend on third-party libraries adopting the hook.

The 4x figure is 0.353 seconds against 1.406 seconds, measured on a "Hello, world" program in Pyodide [2]. That is 1.053 seconds saved per start, a 75% cut in wall time [1]. The summit write-up reports only this Pyodide measurement [2]. For the ratio to hold on another workload, that workload's startup has to be dominated by the work a snapshot skips. That work is parsing and executing imports: the snapshot pays for it once, saves the state, and restores it on later runs [15].

A snapshot is taken after initialization has completed [1]. By then the hash salt drawn at startup is already a value in the saved memory. Every process restored from that image begins with the same one, so a value meant to be random per process becomes deterministic and shared across all snapshots [6]. The write-up illustrates it with xkcd's "Random Number" strip, in which 4 was picked once by a fair dice roll and is returned whenever a random number is requested [16]. The attack the salt blocks was reported to Java, Ruby, PHP and many other projects at the same time [4].

Chatham's remedy is to run the initialization phase again after a restore, so the runtime and third-party libraries can reintroduce randomness [9]. Behnel's reinit callback gets to the same place with a smaller API change. I think the callback is the easier one to adopt, because the atexit module already has Python developers registering a function for a process lifecycle event [10]. I would revise that if CPython wanted the phase for more than re-seeding. The proposal itself is careful work. It starts from the one known breakage and asks for a general hook that libraries can also use [9].

Peter Bierma asked whether faster initialization could make snapshots unnecessary [11]. Chatham said there is a "ton of work" in initializing Python, such as importing modules, and that speeding it up is "an order of magnitude more complex" than snapshotting and reinitialization [11]. "I know how to implement snapshotting, I don't know how to speed up the interpreter," he said [12].

His caution about PEP 810 follows from two definitions. A lazy import is not parsed or executed until a member of the module is accessed [14]. A snapshot gains by paying import costs once, before the save [15]. A module that nothing touches during initialization therefore stays out of the saved image, and every restored process pays for it on first use [2].

What to watch

  • Whether Chatham's initialization phase or Behnel's atexit-style reinit callback is written up as a PEP.
  • A snapshot startup measurement on native CPython, outside Pyodide and beyond a Hello, world program.
  • How a snapshot prototype handles modules deferred by PEP 810 lazy imports once Python 3.15 ships them.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories