GHSA-mv28-wj57-f57g YesWiki Vulnerable to Unauthenticated ActivityPub Signature-Verification Bypass via `!openssl_verify(...)` accepting `int(-1)`
Summary`HttpSignatureService::verifySignature()` checks the result of PHP's `openssl_verify()` with a **loose boolean negation** - `if (!openssl_verify(...)) { throw ... }`. PHP's `openssl_verify` has four possible return values:| return | meaning                                          | `!return` |
| ------ | ------------------------------------------------ | --------- |
| `1`    | signature is valid                               | `false`   |
| `0`    | signature is invalid                             | `true` ✓  |
| `-1`   | the verify call itself failed (internal error)   | **`false` ❌** |
| `false`| input rejected by PHP's argument validation      | `true` ✓  |The `-1` row is the bypass: PHP's truthiness rules make `-1` a truthy value, so `!(-1) === false`, the throw is skipped, and the controller proceeds to `processActivity()`. Any condition that makes OpenSSL's `EVP_VerifyFinal()` return `-1` triggers the bypass.The two practical paths to `-1` we are aware of:**DSA / EC public key with an RSA-only algorithm.** `openssl_verify(..., $dsaKey, "RSA-SHA256")` returns `int(-1)` on PHP 8.3 + OpenSSL 3.x. This is the path the PoC uses; it works against an unmodified `php:8.3-apache` lab and against any deployment using the runtime stack YesWiki's own docker image ships.**Older PHP + older OpenSSL** where any unrecognised digest name returned `-1` rather than `false`. The reporting research mentions this path; on current stacks `false` is returned instead and the throw fires correctly. The DSA path replaces it.The reachable consequence is the same in both cases - the controller silently treats a failed verification as success and processes the attacker's payload.DetailsAffected component**File:** `tools/bazar/services/HttpSignatureService.php`**Method:** `HttpSignatureService::verifySignature(Request $request)`**Sink:** line **130**The inbox controller calls `verifySignature()` and then runs `processActivity($activity, $form)`, which is what actually mutates state.End-to-end attack chainA single unauthenticated POST per operation. No session, no CSRF, no real signature.**Stand up an actor document** that the attacker controls — any public web server (or webhook receiver) that returns a JSON body with the shape:**Send a Create / Update / Delete activity** to `POST /api/forms/{enabled-form-id}/actor/inbox`:**YesWiki fetches the actor document** (line 96 - the SSRF; see sibling advisory), parses it, calls `openssl_get_publickey(...)` which returns a valid OpenSSL key handle (DSA is parsed successfully), then calls `openssl_verify($data, "junk-sig", $dsaKey, "RSA-SHA256")`. EVP_VerifyFinal returns `-1`. The check `!openssl_verify(...)` evaluates to `false` and the throw is skipped.**`Digest` header is enforced**, but it's a simple `SHA-256=` of the body the attacker chose, so satisfying it costs one `sha256sum`.**`processActivity($activity, $form)` runs**: Create → `EntryManager::create()`, Update → `EntryManager::update()`, Delete → `EntryManager::delete()`. The triple store records the attacker's `object.id` as the source URL, which is how Update / Delete locate the entry on subsequent calls.PoCPre ReqsYeswiki v4.6.5 lab image (Setup via podman)ActivityPub enabled on the target formFor the rest of this document:PHP one-liner - runs against the exact PHP+OpenSSL the lab is using. Confirm that `openssl_verify` returns `-1`.**Expected output:**Verify the listener is up and serving the DSA-key actor**Expected output:** a PID, `S` (sleeping/alive), and a JSON document beginning with `{"@context":"https://www.w3.org/ns/activitystreams","id":"http://127.0.0.1:9999/actors/attacker", ...` and a `publicKeyPem` field whose value starts with `-----BEGIN PUBLIC KEY-----\nMIIB...` (the DSA key - note the `Bv` prefix typical of DSA-key DER, not the `Ij` of RSA).Build a JSON Create activity that the Agenda form's reverse-semantic template can map (it expects an `Event` with `name`, `content`, `startTime`, `endTime`, `location.address.*`, etc.):Now, try udating the entry via the same bypassThe triple store records `<tag, sourceUrl, object.id>` from the Create. An Update activity referencing the same `object.id` will look that up and rewrite the entry's body.**Expected output:** `HTTP 200`, empty body.ImpactCRUD on bazar entries of any ActivityPub-enabled form, **without authentication**:**Create** - `EntryManager::create($form['bn_id_nature'], $entry, false, $object['id'])`. New row in `yeswiki_pages` and a triple `<tag, sourceUrl, $object['id']>` in `yeswiki_triples`.**Update** - looks up the entry via the source-URL triple and rewrites its body with the attacker-supplied content.**Delete** - same lookup, then `EntryManager::delete($tag, true)`.Concrete operational impact:**Defacement / content injection** at scale - a public-facing wiki with the Agenda or Blog-actu form federated becomes a publishing target for any attacker who can route TCP to the YesWiki host.**Spam / SEO poisoning** through the Bazar entry body, which is HTML-rendered for the wiki and indexed by search.**Erasure of legitimate federated content** - any entry previously created via ActivityPub can be enumerated through the public outbox endpoint, its `object.id` discovered, and then deleted by replaying the chain with `type=Delete`.**Triple-store pollution** - the `yeswiki_triples` table grows with attacker-controlled `sourceUrl` triples that survive entry deletion and can interfere with later federation flows.**Reputation / federation poisoning** - the wiki appears (to remote ActivityPub peers and to its own users) to be receiving signed content from a remote actor, when in reality anyone on the network can post.
CVSS v3.1 score: 8.2