SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
B3S/WRITEUPS/THROUGH-THE-CRACKS-WEB
← BACK TO ARCHIVE
EVENT: Safaricom PwnZone 2.0 Qualifiers 2026CATEGORY: WebPOINTS: 450 PTS

Through The Cracks

AUTHORED BY:@bealthguy10/5/2026

Through the Cracks

450 points. The blurb was one line: the old theater has seen a few surprising acts.

The first contact produced nothing. Port 8150 did not respond at all. A raw socket test timed out, indicating filtered rather than closed, and a neighbouring port behaved the same way. The endpoint was written off as a deployment that was not yet live.

It became available later. This is worth recording, because a challenge description read before the service is live gives the impression that the target is broken, which it was not.

What it actually was

Once it answered, it was a Python application served by waitress. The page called itself BACKLOT 77, film production, with a section titled PwnZone File Viewer. The interface offered a text box, a View button, a Samples button, and an output pane, with a link labelled Admin in the corner.

The inline JavaScript described the entire interface:

const resp = await fetch(class="hljs-string">'/view?file=' + encodeURIComponent(p));

A single endpoint, /view, takes a file parameter. The Samples button was hardcoded to try files/welcome.txt and then files/help.txt. The first returned:

Welcome to PwnZone!
Try exploring the file viewer.

The second was less descriptive: Sample file. Nothing to see here.

The first mistake

Before trying anything elaborate, /admin was requested because it was present in the interface. It returned 401 with Www-Authenticate: Basic realm="CTF Admin Portal". The shape of the challenge then became clear: read files until the credentials appear, then authenticate.

The bypass

The viewer is an arbitrary file read, established with the obvious traversal:

/view?file=files/../../../../etc/passwd

It returned the real passwd file, with root, daemon, bin, and sys entries present. An unauthenticated file read on the first proper attempt.

A deliberately broken path with a literal quadruple-dot segment, which is not valid traversal, then made the path join fail and the application print its own error:

/view?file=....//....//....//etc/passwd
Error: [Errno 2] No such file or directory: '/app/public/..../..../..../etc/passwd'

That error message discloses the application root. The viewer resolves relative to /app/public/, placing the application source one level up at /app/app.py.

The source

from flask import Flask, request, Response, send_file, render_template_string, abort
...
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "public"))

ADMIN_USER = "panel_admin"
ADMIN_PASS = "k1tsune_2025!"

FLAG1 = os.getenv('FLAG')

The credentials were present in the source file in plain text, two variables below the path constant. The viewer handler confirms the bug:

target_path = os.path.abspath(os.path.join(BASE_DIR, rel_path))

# Intentionally vulnerable: naive check removed (for CTF)
try:
    return send_file(target_path)
except Exception as e:
    # For convenience, also allow reading absolute paths directly
    if os.path.isabs(rel_path) and os.path.exists(rel_path):
        return send_file(rel_path)

abspath normalises the path but nothing constrains it to remain inside BASE_DIR, so .. walks out of the base directory. The fallback branch on the exception is a second bug, and a generous one, because it means absolute paths work as well. A request for /view?file=/etc/passwd returned the passwd file, confirming the reading.

The flag

With panel_admin and k1tsune_2025! available, the admin panel returned:

$ ffcurl -s -u 'panel_admin:k1tsune_2025!' http://54.72.82.22:8150/admin
<h2>Admin Panel</h2><p>Nice work. Here's your flag: <strong>safctf{e9e46fabe7b282f4eb4eff39b378eff3}</strong></p>
safctf{e9e46fabe7b282f4eb4eff39b378eff3}

Two requests returned identical content.

What was never needed

The challenge lists a second service, and considerable effort went into it before the first was available. Port 8240 is Apache Tomcat 9.0.122. Enumeration established that no web application is deployed: thirty thousand paths from the raft directory list were requested with every status captured rather than filtered, twenty-nine thousand nine hundred and ninety two returned 404, and the remaining six were 400s caused by bracket characters in the wordlist. The manager app is absent, the host manager is absent, PUT is rejected, TRACE is rejected, and case variations on WEB-INF and META-INF are rejected.

None of that mattered. There was no SSRF and no proxy chain. The challenge consists of one unauthenticated file read on the front service and a source file with the password in it. The Tomcat host is set dressing, and possibly the reason the front service is exposed at all.

Method notes

The broken path and its error message are the shortest route to the application root, worth more than every wordlist run against the Tomcat port. The Samples button is unnecessary once the first traversal attempt succeeds, although seeing the intended path first is a reasonable precaution.

Writing the traversal attempt down before issuing it is worth keeping. The passwd file arrived on the first attempt and was then left idle while a theory about a hidden console page was composed, instead of reading the source of the application already in front of the request.