PST-054: The signed plugin catalogue index has no freshness or version counter, so an old signed index can be replayed #783

Closed
opened 2026-10-10 10:56:26 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 8 to 10 October 2026, as PST-054. Severity: Low.

The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.


  • Severity: Low
  • Category: supply-chain / signature-replay
  • Location: src/postulo/plugins/catalogue.py (185-241; 229-245,310-340,392-430)
  • Found by: administrator surface and plugins, secrets and cryptography
  • Related: PST-055 (#784), PST-091 (#820)

Who can do it, and what they need. A man-in-the-middle on the catalogue URL or whoever controls the hosting/CDN of the index (not the signing key). Precondition: a catalogue is configured and an administrator presses Refresh/Install, or plugins sync runs at container start.

How it works.

  1. Capture a previously published, validly signed index.json + index.json.sig that lists a plugin version later found vulnerable (or whose wheel URL has since been taken over at the same hash... no: the hash pins the wheel, so the attack is limited to re-offering the exact old wheel).
  2. Serve that pair in place of the current one.
  3. verify() passes (Ed25519 over the bytes, no date or serial in the document), parse() reads only plugins; find() returns the old 'latest'.
  4. The administrator installs, or plugins sync reinstalls, the withdrawn release.

Impact. Rollback to a signed-but-withdrawn plugin version; cannot introduce unsigned code. Low because the publisher must previously have signed the bad release.

Evidence.

catalogue.py:185-220 `parse()` reads `document.get("plugins", [])` and nothing else; catalogue.py:231-234 fetch index and `.sig` then `verify(payload, signature, known[name]["key"])`; catalogue/official/index.json has only a `plugins` key - no generated/expires/serial field.

Verification (verdict: confirmed; severity after verification: Low). Drop the claim about plugins sync: it is version-pinned to the already-installed version (commands/plugins.py:181, #228), so a replay cannot make it install anything new. The attack applies to the administrator's Refresh/Install in Server settings -> Plugins (server_views.py _refresh/_install -> catalogue.fetch/install), plus the digest cache that remember() overwrites with the old index's digests (cosmetic: it only changes the 'from repository' label on uploads). Attacker: whoever controls the index hosting without the signing key, or a MITM if the configured index URL is http://. Remediation: add a signed generated/expires timestamp or a monotonically increasing serial, remember the highest serial seen per repository, and refuse an index that is older or expired. A yanked/withdrawn flag per release would also help.

Suggested remediation. Add generated_at, expires and a monotonically increasing serial to the signed index; refuse an index past its expiry or with a serial lower than the last one remembered per repository (kept_digests already stores per-repository state in the cache and could hold the serial). This is the TUF 'timestamp/snapshot' idea in one field.

Also covered by this issue.

  • Signed catalogue index has no freshness or version counter, so a hijacked index host can replay an older signed index and downgrade a plugin to a vulnerable release (src/postulo/plugins/catalogue.py (229-245,310-340,392-430); Low). Downgrade attack on plugin code that the signature scheme is described as preventing ('a modified index cannot ship code'). Limited to releases the catalogue ever signed.
Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 8 to 10 October 2026, as **PST-054**. Severity: **Low**. The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it. --- - **Severity:** Low - **Category:** supply-chain / signature-replay - **Location:** `src/postulo/plugins/catalogue.py` (185-241; 229-245,310-340,392-430) - **Found by:** administrator surface and plugins, secrets and cryptography - **Related:** PST-055 (#784), PST-091 (#820) **Who can do it, and what they need.** A man-in-the-middle on the catalogue URL or whoever controls the hosting/CDN of the index (not the signing key). Precondition: a catalogue is configured and an administrator presses Refresh/Install, or `plugins sync` runs at container start. **How it works.** 1. Capture a previously published, validly signed index.json + index.json.sig that lists a plugin version later found vulnerable (or whose wheel URL has since been taken over at the same hash... no: the hash pins the wheel, so the attack is limited to re-offering the exact old wheel). 2. Serve that pair in place of the current one. 3. verify() passes (Ed25519 over the bytes, no date or serial in the document), parse() reads only `plugins`; find() returns the old 'latest'. 4. The administrator installs, or `plugins sync` reinstalls, the withdrawn release. **Impact.** Rollback to a signed-but-withdrawn plugin version; cannot introduce unsigned code. Low because the publisher must previously have signed the bad release. **Evidence.** ```text catalogue.py:185-220 `parse()` reads `document.get("plugins", [])` and nothing else; catalogue.py:231-234 fetch index and `.sig` then `verify(payload, signature, known[name]["key"])`; catalogue/official/index.json has only a `plugins` key - no generated/expires/serial field. ``` **Verification** (verdict: confirmed; severity after verification: Low). Drop the claim about `plugins sync`: it is version-pinned to the already-installed version (commands/plugins.py:181, #228), so a replay cannot make it install anything new. The attack applies to the administrator's Refresh/Install in Server settings -> Plugins (server_views.py _refresh/_install -> catalogue.fetch/install), plus the digest cache that remember() overwrites with the old index's digests (cosmetic: it only changes the 'from repository' label on uploads). Attacker: whoever controls the index hosting without the signing key, or a MITM if the configured index URL is http://. Remediation: add a signed `generated`/`expires` timestamp or a monotonically increasing serial, remember the highest serial seen per repository, and refuse an index that is older or expired. A `yanked`/`withdrawn` flag per release would also help. **Suggested remediation.** Add `generated_at`, `expires` and a monotonically increasing `serial` to the signed index; refuse an index past its expiry or with a serial lower than the last one remembered per repository (kept_digests already stores per-repository state in the cache and could hold the serial). This is the TUF 'timestamp/snapshot' idea in one field. **Also covered by this issue.** - **Signed catalogue index has no freshness or version counter, so a hijacked index host can replay an older signed index and downgrade a plugin to a vulnerable release** (`src/postulo/plugins/catalogue.py` (229-245,310-340,392-430); Low). Downgrade attack on plugin code that the signature scheme is described as preventing ('a modified index cannot ship code'). Limited to releases the catalogue ever signed.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:54 +00:00
Author
Owner

Fixed in c077d02bf on main.

Fixed in c077d02bf on main.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#783
No description provided.