Skip to content

fix(optional-skills): add HMAC integrity check before pickle.loads in show_snapshot - #34986

Open
ErnestHysa wants to merge 2 commits into
NousResearch:mainfrom
ErnestHysa:fix/pickle-snapshot-hmac-verification
Open

fix(optional-skills): add HMAC integrity check before pickle.loads in show_snapshot#34986
ErnestHysa wants to merge 2 commits into
NousResearch:mainfrom
ErnestHysa:fix/pickle-snapshot-hmac-verification

Conversation

@ErnestHysa

Copy link
Copy Markdown
Contributor

Summary\n\nAdds HMAC-SHA256 integrity verification before calling pickle.loads() on untrusted snapshot files in optional-skills/research/darwinian-evolver/scripts/show_snapshot.py.\n\n## Changes\n\n- When HERMES_SNAPSHOT_SECRET env var is set: verifies the snapshot's HMAC signature before unpickling. Rejects the file if the HMAC is missing or doesn't match.\n- When HERMES_SNAPSHOT_SECRET is not set: emits a warning to stderr that integrity verification is skipped.\n\n## Security Fix (PICKLE-001)\n\nWithout HMAC verification, a MITM or compromised output directory could swap a benign snapshot for a malicious one. The new integrity check gates pickle.loads() behind a cryptographic HMAC computed with a shared secret.\n\n## Test Plan\n\n1. Run without HERMES_SNAPSHOT_SECRET set — should emit a warning but still run.\n2. Set HERMES_SNAPSHOT_SECRET and run on a snapshot with an invalid HMAC — should exit with an error.\n3. Set HERMES_SNAPSHOT_SECRET and run on a snapshot with a valid HMAC — should pass verification and proceed.\n\nFixes PICKLE-001.

@liuhao1024

Copy link
Copy Markdown
Contributor

The HMAC verification order defeats its own security purpose.

The PR calls pickle.loads(raw_bytes) at the new code block to extract the outer dict and read the hmac field — before verifying the HMAC. This means any malicious payload embedded in the pickle file is executed during extraction, regardless of whether the HMAC check passes or fails. The pickle.loads call IS the attack vector; verifying integrity after it runs is like checking a lock after opening the door.

The original code already has a pickle.loads at line 65, gated behind --i-trust-this-file. The PR adds a second pickle.loads that runs unconditionally (only gated by the env var being set), which actually increases the attack surface.

Additionally: HERMES_SNAPSHOT_SECRET="HERMES...CRET" appears to be a truncated placeholder, not a real secret. If this is committed as-is, os.environ.get(HERMES_SNAPSHOT_SECRET) will look for an env var named literally HERMES...CRET.

To make this work correctly, the HMAC would need to be stored in a sidecar file or in a non-pickle outer envelope (e.g., a JSON header with the HMAC + base64-encoded pickle payload), so integrity can be verified without unpickling first.

@alt-glitch

Copy link
Copy Markdown
Collaborator

Duplicate/competing: Underlying pickle issue already fixed by merged #29276 (guards pickle.loads with --i-trust-this-file flag). This HMAC approach is logically flawed — pickle.loads() runs BEFORE the HMAC check, defeating the purpose. Related to #28564 (docs security note).

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P3 Low — cosmetic, nice to have tool/skills Skills system (list, view, manage) duplicate This issue or pull request already exists labels May 29, 2026
The previous implementation called pickle.loads(raw_bytes) to extract the
'hmac' field from the unpickled dict, then compared it to a recomputed HMAC.
This is the exact attack surface pickle.loads is supposed to defend against —
the integrity check happens *after* the untrusted code has already executed,
defeating the purpose entirely.

This commit moves the HMAC to a sidecar file (`snapshot.pkl.hmac`) and
verifies the raw file bytes against the sidecar before any unpickling.
Sidecar format: a single line of hex SHA-256 HMAC-SHA256, written by the
producer with the same secret. If the sidecar is missing, verification fails
closed.

Also removes the unused base64 import.
@ErnestHysa

Copy link
Copy Markdown
Contributor Author

@liuhao1024 — you were right: the previous code called pickle.loads(raw_bytes) before the HMAC check, which is exactly the attack surface pickle.loads is supposed to defend against. The integrity check ran after untrusted code had already executed.

Reworked in commit 44ce026. The HMAC is now stored in a sidecar file (snapshot.pkl.hmac) and verified against the raw bytes of the snapshot — no unpickling happens until the HMAC passes. Sidecar is a single line of hex SHA-256 HMAC-SHA256 written by the producer. Missing sidecar fails closed.

About the HERMES_SNAPSHOT_SECRET="HERMES...CRET" placeholder: that was a misleading const assignment in my last revision. The lookup is os.environ.get(HERMES_SNAPSHOT_SECRET) where the const is the env var name, not the secret value. I've kept the same constant so the env var name remains explicit and discoverable, but happy to refactor to a string literal if you'd prefer.

Smoke tests:

  • valid HMAC + valid sidecar → proceeds
  • tampered file, valid sidecar → exits with HMAC failure
  • missing sidecar → exits with sidecar-not-found
  • no HERMES_SNAPSHOT_SECRET set → warns and proceeds (no integrity check)

@ErnestHysa

Copy link
Copy Markdown
Contributor Author

@alt-glitch — addressed in commit 44ce026. You were right: the previous approach was logically flawed (pickle.loads ran before the HMAC check). Reworked to use a sidecar .pkl.hmac file containing a single line of hex SHA-256 HMAC, verified against the raw file bytes before any unpickling. Producer writes the sidecar with the same secret; missing sidecar fails closed. Verification: 3 smoke tests passed (valid HMAC, tampered file rejected, missing sidecar rejected). Pushed to fork.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for correcting the original verification ordering: the sidecar approach verifies raw bytes before unpickling.

Problems

  • The reader change is not paired with a producer. optional-skills/research/darwinian-evolver/templates/custom_problem_template.py:231 writes only iteration_<n>.pkl; the new show_snapshot.py logic requires iteration_<n>.pkl.hmac whenever HERMES_SNAPSHOT_SECRET is set. Consequently, all snapshots generated by the shipped template fail closed with a missing-sidecar error.
  • tests/skills/test_darwinian_evolver_skill.py:64-74 only syntax-checks the scripts. Add behavioral coverage for valid, missing, and tampered sidecars, including a check that pickle.loads is never reached after a verification failure.
  • The documented command at optional-skills/research/darwinian-evolver/SKILL.md:107-112 does not document the new sidecar setup and omits the existing required --i-trust-this-file flag.

Suggested changes

  • Implement and document the matching sidecar producer, then test the complete producer/reader contract.

Current main already requires an explicit trust acknowledgement before its pickle.loads calls (show_snapshot.py:42-62, added by 0a2ee71cc). This is an automated hermes-sweeper review.

secret = os.environ.get(HERMES_SNAPSHOT_SECRET)
if secret:
hmac_path = args.snapshot.with_suffix(args.snapshot.suffix + ".hmac")
if not hmac_path.exists():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No shipped producer writes this sidecar: templates/custom_problem_template.py:231 writes only the .pkl bytes. With HERMES_SNAPSHOT_SECRET enabled, every documented snapshot therefore exits here. Please add the matching producer and behavioral tests before requiring this file.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 13, 2026
@alt-glitch alt-glitch removed duplicate This issue or pull request already exists sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P3 Low — cosmetic, nice to have sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users tool/skills Skills system (list, view, manage) type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants