Signed by Anyone, Checked on Something Else: hyper-mcp’s Two Cosign Bypasses

There is a failure mode worse than skipping a signature check: running one that always succeeds. On 11 October 2026 at 14:17:04Z, NVD published two records against hyper-mcp, a Rust MCP server that loads its tools as WebAssembly plugins pulled from OCI registries. CVE-2026-108698 (CWE-367, time-of-check/time-of-use) and CVE-2026-108699 (CWE-347, improper verification of cryptographic signature) are both scored 8.3 by VulnCheck under the identical vector CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N. Both are credited to hieuPenguinnn. Both carry no fixed version.

The two defects are independent, and either one alone is sufficient to load an attacker’s WebAssembly into the plugin host. Together they describe a verification routine that checks the wrong artifact against the wrong policy. We read src/wasm/oci.rs and src/config.rs at tag v0.8.3 and again at main, and the detail that stayed with us is not in either advisory: the value needed to close the first bug is already bound to a local variable eighteen lines above the call that gets it wrong.

The digest was already in scope

hyper-mcp’s load_wasm resolves the image, then verifies, then downloads. The ordering is right; the argument is not. The function pulls a manifest and receives two things back:

let (manifest, manifest_digest) = (|| async {
    client.pull_manifest(&reference, &auth).await.inspect_err(|e| {
        tracing::warn!(image = image_reference, error = ?e, "Failed to pull manifest, retrying");
    })
})
.retry(download_backoff())
.await?;

manifest_digest is the content address of the exact bytes the loader is about to act on. It is used immediately afterwards to name the on-disk cache entry, splitting on the colon to build an algo_sha filename. Then, eighteen lines later, under a comment that announces the correct intention:

// Verify BEFORE downloading blobs
verify_image_signature_with_cosign(config, image_reference).await?;

image_reference is the tag string — ghcr.io/org/plugin:latest. It is not manifest_digest. verify_image_signature_with_cosign shells out to the cosign binary with that string as its final argument, and cosign dutifully performs its own, second resolution of the tag against the registry. Two independent resolutions of a mutable pointer, separated by a network round trip, with nothing binding them together. A registry operator — or anyone who can answer for it, which is what AT:P in the vector is paying for — serves an unsigned malicious manifest to the loader and a legitimately signed one to cosign. Verification passes. The plugin that runs is the other one.

That is the advisory’s claim, and the source supports it. Our addition is the remedy’s size. Cosign accepts digest references; passing image@manifest_digest instead of image_reference would pin the second resolution to the first. The variable exists, is in scope, is already trusted enough to key the cache, and is not used. This is not a design that needs rethinking. It is an argument.

“Strictest possible verification”

The second CVE is the more interesting one, because the code is not confused about what it is doing. cosign_verify_args carries this doc comment at v0.8.3:

“Build cosign args for the strictest possible verification given your OciConfig.”

It then branches on whether the operator configured a certificate identity. If cert_email or cert_url is set, cert_issuer becomes mandatory and both are passed through as regexes — genuinely strict. If neither is set, the function takes the other branch:

None => {
    // accept any valid keyless signature, but still require identity/issuer regex presence
    args.push("--certificate-identity-regexp".into());
    args.push(".*".into());

    args.push("--certificate-oidc-issuer-regexp".into());
    args.push(".*".into());
}

Sigstore keyless signing does not prove that a trusted party signed an artifact. It proves that some OIDC identity did, and records which one. The entire security property lives in the policy the verifier applies to that identity. .* for identity and .* for issuer discards it completely. Any GitHub, Google or Microsoft account can obtain a Fulcio certificate and sign an image in under a minute, at no cost, and that signature satisfies this check.

The comment is candid about the mechanism — it says “accept any valid keyless signature” — but it misreads the consequence. It treats the presence of the flags as the control. The flags are not the control; their values are. Requiring “identity/issuer regex presence” while setting both to match-anything produces a cosign invocation that exits zero for every signed image in existence.

The default is what ships

This would be a documentation problem if the strict path were the default. It is not. We read OciConfig’s Default implementation at v0.8.3:

OciConfig {
    cert_email: None,
    cert_issuer: None,
    cert_url: None,
    fulcio_certs: None,
    insecure_skip_signature: false,
    rekor_pub_keys: None,
    use_sigstore_tuf_data: true,
}

Three identity fields, all None — so the default configuration takes the .* branch. And insecure_skip_signature is false, which is the part that does the damage. An operator reading this struct, or the config file it serialises to, sees signature verification explicitly enabled and no setting that looks disabled. It is enabled. It runs. It logs “Cosign verification successful” at info level. It tells you nothing about who signed the plugin you just loaded into a host with filesystem and environment capabilities.

The two CVEs compose cleanly, and in opposite directions. CVE-2026-108699 is the one an attacker who can publish to a registry reference uses — no race, no privileged network position, just a free certificate. CVE-2026-108698 is the one that defeats a correctly configured deployment, where an operator has set cert_email and cert_issuer and would otherwise be protected, because the strict policy is applied to a manifest the loader never saw.

The tests assert the weakness

We checked whether either defect had been addressed after disclosure. At main on the day of publication, verify_image_signature_with_cosign(config, image_reference) is unchanged, and cosign_verify_args still emits .* for both constraints. That is unsurprising five hours after an NVD record appears.

What is more telling is what else is at main. The file’s test module contains assertions of this shape:

assert!(args.contains(&"--certificate-identity-regexp".to_string()));
assert!(args.contains(&".*".to_string()));

The permissive default is not an oversight that slipped past review. It is a documented, tested, intended behaviour — a regression suite exists specifically to keep .* in the argument list. Our reading, and it is ours rather than the advisory’s: a maintainer tightening this will not merely change a default, they will see a test fail and have to decide that the test was wrong. That is a meaningfully higher bar than fixing a typo, and it is the reason we would not expect this one to be quick.

There is a third observation the advisories do not make, and we flag it with its caveat. The cache check in load_wasm returns before verification is reached:

if local_output_path.exists() {
    tracing::info!(image = image_reference, path = ?local_output_path, "Plugin already cached. Skipping downloading.");
    return Ok(fs::read(local_output_path).await?);
}

Taken alone this is defensible — the cache is keyed by manifest digest, so a hit is the same content that was verified on first pull. The caveat is that under CVE-2026-108698 “verified on first pull” is exactly the guarantee in question. A manifest that wins the race once is written to cache under its own digest and served thereafter without cosign running at all. Persistence is free.

What is actually exposed

Scope matters here and we will not overstate it. hyper-mcp has 882 stars and 66 forks, which places it well below the MCP infrastructure we have covered at the SDK layer. The at-risk population is deployments that load plugins from an OCI registry an attacker can influence, or that rely on signature verification as the control permitting third-party plugins at all.

That second case is the one worth naming. The reason to distribute MCP plugins as signed OCI artifacts rather than as source is to make “we only run plugins from people we trust” an enforceable statement. A verifier that accepts .* converts that enforceable statement back into a social convention while continuing to emit the log lines of an enforcement system. The population most exposed is the one that adopted signing deliberately.

On remediation: the newest published non-prerelease is v0.8.3, tagged 24 June 2026. src/wasm/oci.rs was last modified on 31 May 2026, in a commit titled “Migrate from backoff to backon” — a dependency change, not a security one. The repository is active, last pushed 9 October 2026, and publishes a rolling nightly prerelease, most recently on 11 October 2026 at 03:20:32Z, eleven hours before the CVEs appeared. There is no version to upgrade to. This is the same remediation shape we documented for PraisonAI, openPDC and JeecgBoot, and it keeps recurring for the same structural reason: coordinated disclosure through a third-party CNA sets a publication date, and a volunteer release cadence does not move to meet it.

The mitigations that exist today

Both defects are configuration-reachable, which is unusual and useful. Operators can act without a patch:

  • Set cert_email (or cert_url) and cert_issuer. This moves cosign_verify_args onto the strict branch and eliminates CVE-2026-108699 outright. The code enforces that cert_issuer accompanies an identity, so a half-configured policy fails loudly rather than silently widening.
  • Reference plugins by digest, not by tag. If the configured plugin URL is already image@sha256:…, both resolutions of “the reference” are the same immutable object and the TOCTOU window closes. This is a deployment-side change that does not depend on the project shipping anything.
  • Do not treat insecure_skip_signature: false as evidence. Verification being on is not the same as verification being meaningful. Check the identity constraints.

The second item is the general lesson, and it is older than MCP. Tags are mutable pointers; digests are content addresses. Any verification pipeline that resolves a mutable pointer more than once has a race in it, whether or not anyone has written the CVE yet. hyper-mcp resolved it twice and had the digest from the first resolution sitting in a variable the whole time.

Verification note. CVE identifiers, CVSS 4.0 vectors, CWE assignments, publication timestamps (both 2026-10-11T14:17:04Z), the Received NVD status and the assigning source (disclosure@vulncheck.com) were read first-hand from the NVD 2.0 API. Severity, dates and the credit to hieuPenguinnn were read from the VulnCheck advisory pages. All source excerpts — load_wasm, verify_image_signature_with_cosign, cosign_verify_args, OciConfig::default, the cache-hit early return and the test assertions — were read directly from the repository at tag v0.8.3 and at main, and are quoted verbatim. Release tags, dates, star and fork counts, the last-push timestamp and the commit history of src/wasm/oci.rs came from the GitHub API. The observation that manifest_digest is in scope at the verification call site, the reading of the cache-hit path, the argument about tested-in defaults, and the characterisation of who is most exposed are our analysis and are labelled as such, not vendor or advisory statements. We found no evidence of exploitation and do not imply any. We did not pull, sign, or load any plugin image, and ran no code against any registry or hyper-mcp instance.

Sources: